type UnknownRecord = Record; export type CatalogueSortDirection = 'asc' | 'desc'; export type SortDirection = CatalogueSortDirection; export type CatalogueSortKey = | 'relevance' | 'recommended' | 'featured' | 'name' | 'category' | 'difficulty' | 'status' | 'newest' | 'updated' | 'popularity' | (string & {}); export type MachineSortKey = CatalogueSortKey; export type CatalogueFilterValue = | string | number | boolean | readonly (string | number | boolean)[] | ReadonlySet | null | undefined; export interface CatalogueQueryOptions { search?: unknown; query?: unknown; q?: unknown; searchTerm?: unknown; category?: CatalogueFilterValue; categories?: CatalogueFilterValue; categoryId?: CatalogueFilterValue; categoryFilter?: CatalogueFilterValue; difficulty?: CatalogueFilterValue; difficulties?: CatalogueFilterValue; difficultyId?: CatalogueFilterValue; difficultyFilter?: CatalogueFilterValue; complexity?: CatalogueFilterValue; status?: CatalogueFilterValue; statuses?: CatalogueFilterValue; statusFilter?: CatalogueFilterValue; availability?: CatalogueFilterValue; tag?: CatalogueFilterValue; tags?: CatalogueFilterValue; tagFilter?: CatalogueFilterValue; keywords?: CatalogueFilterValue; tagOperator?: 'all' | 'any' | unknown; availableOnly?: unknown; onlyAvailable?: unknown; favoritesOnly?: unknown; favouritesOnly?: unknown; favoriteOnly?: unknown; favouriteOnly?: unknown; bookmarkedOnly?: unknown; favoriteIds?: Iterable | string | number | null; favouriteIds?: Iterable | string | number | null; favorites?: Iterable | string | number | null; favourites?: Iterable | string | number | null; bookmarkIds?: Iterable | string | number | null; bookmarks?: Iterable | string | number | null; sortBy?: CatalogueSortKey | string | null; sort?: CatalogueSortKey | string | null; sortDirection?: CatalogueSortDirection | string | null; direction?: CatalogueSortDirection | string | null; orderBy?: CatalogueSortKey | string | null; order?: CatalogueSortKey | string | null; orderDirection?: CatalogueSortDirection | string | null; [key: string]: unknown; } export interface NormalisedSortPreference { sortBy: CatalogueSortKey; direction: CatalogueSortDirection; } export interface NormalisedCatalogueQuery { search: string; categories: string[]; difficulties: string[]; statuses: string[]; tags: string[]; tagOperator: 'all' | 'any'; availableOnly: boolean; favoritesOnly: boolean; favoriteIds: Set; sortBy: CatalogueSortKey; sortDirection: CatalogueSortDirection; hasExplicitSort: boolean; raw: CatalogueQueryOptions; } export interface MachineSearchIndexEntry { id: string; slug: string; title: string; text: string; tokens: string[]; machine: TMachine; } export interface RankedMachine { machine: TMachine; score: number; index: number; } export const DIFFICULTY_ORDER = [ 'novice', 'beginner', 'introductory', 'foundational', 'intermediate', 'advanced', 'expert', ] as const; export const CATALOGUE_SORT_OPTIONS = [ { value: 'relevance', label: 'Relevance' }, { value: 'recommended', label: 'Recommended' }, { value: 'name', label: 'Name A-Z' }, { value: 'category', label: 'Category' }, { value: 'difficulty', label: 'Difficulty' }, { value: 'newest', label: 'Newest' }, { value: 'updated', label: 'Recently updated' }, { value: 'popularity', label: 'Most bookmarked' }, ] as const; export const SORT_OPTIONS = CATALOGUE_SORT_OPTIONS; const EMPTY_FILTER_VALUES = new Set([ '', '*', 'all', 'any', 'none', 'no filter', 'all categories', 'all difficulties', 'all statuses', 'all tags', 'all machines', 'everything', ]); const PREFERRED_TEXT_KEYS = [ 'name', 'title', 'label', 'displayName', 'subtitle', 'summary', 'shortDescription', 'description', 'overview', 'id', 'slug', 'value', ] as const; const PREFERRED_TEXT_KEY_SET = new Set(PREFERRED_TEXT_KEYS); const IGNORED_OBJECT_KEYS = new Set([ 'asset', 'assets', 'background', 'color', 'colors', 'gradient', 'heroImage', 'icon', 'image', 'images', 'material', 'materials', 'model', 'modelPath', 'thumbnail', 'texture', 'textures', ]); const SEARCH_FIELD_KEYS = [ ...PREFERRED_TEXT_KEYS, 'aliases', 'applications', 'category', 'categoryId', 'categoryName', 'categorySlug', 'complexity', 'components', 'componentNames', 'cycle', 'difficulty', 'difficultyId', 'difficultyLabel', 'difficultyLevel', 'engineeringFacts', 'facts', 'features', 'highlights', 'keywords', 'layout', 'manufacturer', 'mechanisms', 'metrics', 'parts', 'releasePhase', 'specifications', 'specs', 'status', 'systems', 'tags', 'type', 'useCases', ] as const; const CATEGORY_FIELD_KEYS = [ 'category', 'categoryId', 'categoryName', 'categorySlug', 'type', 'systemType', ] as const; const DIFFICULTY_FIELD_KEYS = [ 'difficulty', 'difficultyId', 'difficultyLabel', 'difficultyLevel', 'complexity', 'level', ] as const; const STATUS_FIELD_KEYS = ['status', 'availability', 'releasePhase', 'phase'] as const; const TAG_FIELD_KEYS = ['tags', 'tag', 'keywords', 'labels', 'topics'] as const; const IDENTITY_FIELD_KEYS = [ 'id', 'slug', 'route', 'routeId', 'routeSlug', 'key', 'name', 'title', ] as const; const UPDATED_DATE_FIELD_KEYS = [ 'updatedAt', 'lastUpdated', 'modifiedAt', 'revisedAt', 'revisionDate', ] as const; const CREATED_DATE_FIELD_KEYS = [ 'releasedAt', 'releaseDate', 'publishedAt', 'createdAt', 'introducedAt', 'updatedAt', ] as const; const POPULARITY_FIELD_KEYS = [ 'popularity', 'bookmarks', 'bookmarkCount', 'favoriteCount', 'favouriteCount', 'views', 'viewCount', 'score', 'rating', ] as const; const FEATURED_FIELD_KEYS = ['featured', 'isFeatured', 'recommended', 'isRecommended'] as const; const UNAVAILABLE_STATUS_TOKENS = [ 'archived', 'coming soon', 'concept', 'deprecated', 'disabled', 'draft', 'planned', 'prototype', 'unavailable', ]; const DESCENDING_SORT_KEYS = new Set([ 'relevance', 'recommended', 'featured', 'newest', 'updated', 'popularity', ]); const DIFFICULTY_WEIGHTS = DIFFICULTY_ORDER.reduce>( (weights, difficulty, index) => { weights[difficulty] = (index + 1) * 10; return weights; }, {}, ); const SORT_KEY_ALIASES: Record = { alphabetical: 'name', az: 'name', 'a-z': 'name', category: 'category', complexity: 'difficulty', created: 'newest', difficulty: 'difficulty', featured: 'featured', level: 'difficulty', match: 'relevance', name: 'name', newest: 'newest', popular: 'popularity', popularity: 'popularity', recent: 'newest', recommended: 'recommended', relevance: 'relevance', 'release-date': 'newest', status: 'status', title: 'name', updated: 'updated', za: 'name', 'z-a': 'name', }; const DIRECTIONAL_SORT_ALIASES: Record< string, { sortBy: CatalogueSortKey; direction: CatalogueSortDirection } > = { 'a-z': { sortBy: 'name', direction: 'asc' }, az: { sortBy: 'name', direction: 'asc' }, 'difficulty-high-low': { sortBy: 'difficulty', direction: 'desc' }, 'difficulty-low-high': { sortBy: 'difficulty', direction: 'asc' }, 'least-popular': { sortBy: 'popularity', direction: 'asc' }, 'newest-first': { sortBy: 'newest', direction: 'desc' }, oldest: { sortBy: 'newest', direction: 'asc' }, 'oldest-first': { sortBy: 'newest', direction: 'asc' }, 'popular-first': { sortBy: 'popularity', direction: 'desc' }, 'recently-updated': { sortBy: 'updated', direction: 'desc' }, 'z-a': { sortBy: 'name', direction: 'desc' }, za: { sortBy: 'name', direction: 'desc' }, }; function isRecord(value: unknown): value is UnknownRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isIterable(value: unknown): value is Iterable { return ( typeof value !== 'string' && value !== null && value !== undefined && typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function' ); } function unique(values: readonly TValue[]): TValue[] { return Array.from(new Set(values)); } export function stringifySearchValue( value: unknown, depth = 0, seen: WeakSet = new WeakSet(), ): string { if (value === null || value === undefined) { return ''; } if ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' ) { return String(value); } if (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { if (seen.has(value)) { return ''; } seen.add(value); return value .map((entry) => stringifySearchValue(entry, depth + 1, seen)) .filter(Boolean) .join(' '); } if (!isRecord(value)) { return ''; } if (seen.has(value)) { return ''; } seen.add(value); const preferredParts = PREFERRED_TEXT_KEYS.map((key) => stringifySearchValue(value[key], depth + 1, seen), ).filter(Boolean); if (depth >= 3) { return preferredParts.join(' '); } const remainingParts = Object.entries(value) .filter(([key, nestedValue]) => { return ( nestedValue !== null && nestedValue !== undefined && !PREFERRED_TEXT_KEY_SET.has(key) && !IGNORED_OBJECT_KEYS.has(key) ); }) .map(([, nestedValue]) => stringifySearchValue(nestedValue, depth + 1, seen)) .filter(Boolean); return [...preferredParts, ...remainingParts].join(' '); } export function normaliseSearchText(value: unknown): string { const text = stringifySearchValue(value); if (!text) { return ''; } return text .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[μµ]/g, ' micro ') .replace(/×/g, ' x ') .replace(/&/g, ' and ') .replace(/[`'’]/g, '') .replace(/[^\p{L}\p{N}]+/gu, ' ') .toLowerCase() .trim() .replace(/\s+/g, ' '); } export const normalizeSearchText = normaliseSearchText; export const normaliseSearchTerm = normaliseSearchText; export const normalizeSearchTerm = normaliseSearchText; export function tokeniseSearchText(value: unknown): string[] { const normalised = normaliseSearchText(value); return normalised ? normalised.split(' ') : []; } export const tokenizeSearchText = tokeniseSearchText; export const tokeniseSearchQuery = tokeniseSearchText; export const tokenizeSearchQuery = tokeniseSearchText; function extractComparableStrings( value: unknown, seen: WeakSet = new WeakSet(), ): string[] { if (value === null || value === undefined) { return []; } if ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' ) { return [String(value)]; } if (value instanceof Date) { return [value.toISOString()]; } if (Array.isArray(value)) { if (seen.has(value)) { return []; } seen.add(value); return value.flatMap((entry) => extractComparableStrings(entry, seen)); } if (!isRecord(value)) { return []; } if (seen.has(value)) { return []; } seen.add(value); const directParts = PREFERRED_TEXT_KEYS.flatMap((key) => value[key] === null || value[key] === undefined ? [] : extractComparableStrings(value[key], seen), ); const fullText = stringifySearchValue(value, 0, new WeakSet()); return unique([...directParts, fullText].map((entry) => entry.trim()).filter(Boolean)); } function readCombinedFields(record: UnknownRecord | undefined, keys: readonly string[]): string { if (!record) { return ''; } return keys.flatMap((key) => extractComparableStrings(record[key])).join(' '); } function getComparableFieldValues(machine: unknown, keys: readonly string[]): string[] { if (!isRecord(machine)) { return []; } return unique( keys .flatMap((key) => extractComparableStrings(machine[key])) .map(normaliseSearchText) .filter(Boolean), ); } export function getMachineSearchFields(machine: unknown): string[] { if (!isRecord(machine)) { const fallback = stringifySearchValue(machine); return fallback ? [fallback] : []; } const fields = SEARCH_FIELD_KEYS.flatMap((key) => extractComparableStrings(machine[key])).map( (field) => field.trim(), ); if (fields.length === 0) { const fallback = stringifySearchValue(machine); return fallback ? [fallback] : []; } return unique(fields.filter(Boolean)); } export function getSearchableMachineText(machine: unknown): string { return normaliseSearchText(getMachineSearchFields(machine).join(' ')); } export const getMachineSearchText = getSearchableMachineText; export const createSearchableText = getSearchableMachineText; export function getMachineTitle(machine: unknown): string { if (!isRecord(machine)) { return stringifySearchValue(machine) || 'Untitled machine'; } const title = readCombinedFields(machine, ['name', 'title', 'label', 'displayName']).trim(); return title || getMachineRouteKey(machine) || 'Untitled machine'; } export function getMachineRouteKey(machine: unknown): string { if (!isRecord(machine)) { return ''; } for (const key of IDENTITY_FIELD_KEYS) { const [candidate] = extractComparableStrings(machine[key]); if (candidate?.trim()) { return candidate.trim(); } } return ''; } function getMachineIdentityCandidates(machine: unknown): string[] { if (!isRecord(machine)) { return []; } const rawCandidates = IDENTITY_FIELD_KEYS.flatMap((key) => extractComparableStrings(machine[key])) .map((candidate) => candidate.trim()) .filter(Boolean); return unique([...rawCandidates, ...rawCandidates.map(normaliseSearchText)]); } export function scoreMachineForQuery(machine: unknown, query: unknown): number { const tokens = tokeniseSearchText(query); if (tokens.length === 0) { return 0; } const searchableText = getSearchableMachineText(machine); if (!tokens.every((token) => searchableText.includes(token))) { return Number.NEGATIVE_INFINITY; } const phrase = tokens.join(' '); const record = isRecord(machine) ? machine : undefined; const titleText = normaliseSearchText( readCombinedFields(record, ['name', 'title', 'label', 'displayName']), ); const routeText = normaliseSearchText(readCombinedFields(record, ['slug', 'id', 'routeSlug'])); const tagText = normaliseSearchText(readCombinedFields(record, TAG_FIELD_KEYS)); const categoryText = normaliseSearchText(readCombinedFields(record, CATEGORY_FIELD_KEYS)); const componentText = normaliseSearchText( readCombinedFields(record, ['components', 'componentNames', 'parts']), ); const summaryText = normaliseSearchText( readCombinedFields(record, ['summary', 'shortDescription', 'description', 'overview']), ); let score = 0; if (titleText === phrase) { score += 500; } else if (titleText.startsWith(phrase)) { score += 350; } else if (titleText.includes(phrase)) { score += 250; } if (routeText === phrase) { score += 220; } else if (routeText.includes(phrase)) { score += 120; } if (tokens.length > 1 && searchableText.includes(phrase)) { score += 80; } const weightedFields = [ { text: titleText, exactWord: 46, partial: 26 }, { text: routeText, exactWord: 34, partial: 18 }, { text: tagText, exactWord: 30, partial: 16 }, { text: categoryText, exactWord: 20, partial: 10 }, { text: componentText, exactWord: 18, partial: 9 }, { text: summaryText, exactWord: 10, partial: 5 }, ]; for (const token of tokens) { for (const field of weightedFields) { if (!field.text) { continue; } const words = field.text.split(' '); if (words.includes(token)) { score += field.exactWord; } else if (field.text.startsWith(token)) { score += Math.round(field.partial * 1.5); } else if (field.text.includes(token)) { score += field.partial; } } const firstIndex = searchableText.indexOf(token); if (firstIndex >= 0) { score += Math.max(1, 12 - Math.floor(firstIndex / 30)); } } return score; } export function machineMatchesSearch(machine: unknown, query: unknown): boolean { const tokens = tokeniseSearchText(query); if (tokens.length === 0) { return true; } return scoreMachineForQuery(machine, query) > Number.NEGATIVE_INFINITY; } export const matchesMachineQuery = machineMatchesSearch; export const matchesSearch = machineMatchesSearch; function flattenFilterValue(value: unknown): unknown[] { if (value === null || value === undefined) { return []; } if (typeof value === 'string') { return value.split(/[|,]/g); } if (Array.isArray(value)) { return value.flatMap(flattenFilterValue); } if (value instanceof Set) { return Array.from(value).flatMap(flattenFilterValue); } if (isIterable(value)) { return Array.from(value).flatMap(flattenFilterValue); } return [value]; } export function normaliseFilterList(...values: unknown[]): string[] { return unique( values .flatMap(flattenFilterValue) .map(normaliseSearchText) .filter((value) => value && !EMPTY_FILTER_VALUES.has(value)), ); } export const normalizeFilterList = normaliseFilterList; function flattenIdentityValue(value: unknown): unknown[] { if (value === null || value === undefined) { return []; } if (typeof value === 'string') { return value.includes(',') || value.includes('|') ? value.split(/[|,]/g) : [value]; } if (Array.isArray(value)) { return value.flatMap(flattenIdentityValue); } if (value instanceof Set) { return Array.from(value).flatMap(flattenIdentityValue); } if (isIterable(value)) { return Array.from(value).flatMap(flattenIdentityValue); } return [value]; } function normaliseIdentitySet(...values: unknown[]): Set { const identities = new Set(); for (const value of values.flatMap(flattenIdentityValue)) { const rawValue = stringifySearchValue(value).trim(); const normalisedValue = normaliseSearchText(rawValue); if (rawValue) { identities.add(rawValue); } if (normalisedValue) { identities.add(normalisedValue); } } return identities; } function firstString(...values: unknown[]): string { for (const value of values) { const candidates = extractComparableStrings(value); for (const candidate of candidates) { const trimmed = candidate.trim(); if (trimmed) { return trimmed; } } } return ''; } function normaliseSortDirection(value: unknown): CatalogueSortDirection | undefined { const normalised = normaliseSearchText(value); if (!normalised) { return undefined; } const compact = normalised.replace(/\s+/g, '-'); if (['asc', 'ascending', 'up', 'low-high', 'low-to-high', 'oldest-first'].includes(compact)) { return 'asc'; } if (['desc', 'descending', 'down', 'high-low', 'high-to-low', 'newest-first'].includes(compact)) { return 'desc'; } return undefined; } export function defaultDirectionForSort(sortBy: CatalogueSortKey): CatalogueSortDirection { const key = normaliseSearchText(sortBy).replace(/\s+/g, '-'); return DESCENDING_SORT_KEYS.has(key as CatalogueSortKey) ? 'desc' : 'asc'; } export function normaliseSortPreference( sortBy: unknown = 'name', directionInput?: unknown, ): NormalisedSortPreference { let raw = normaliseSearchText(sortBy); let compact = raw.replace(/\s+/g, '-'); const explicitDirection = normaliseSortDirection(directionInput); let direction = explicitDirection; if (!compact || EMPTY_FILTER_VALUES.has(raw)) { return { sortBy: 'name', direction: direction ?? defaultDirectionForSort('name'), }; } const directionalAlias = DIRECTIONAL_SORT_ALIASES[compact]; if (directionalAlias) { return { sortBy: directionalAlias.sortBy, direction: direction ?? directionalAlias.direction, }; } const suffixMatch = compact.match(/^(.*?)-(asc|ascending|desc|descending)$/); if (suffixMatch) { compact = suffixMatch[1]; raw = compact.replace(/-/g, ' '); direction = direction ?? (suffixMatch[2].startsWith('desc') ? 'desc' : 'asc'); } const sortKey = SORT_KEY_ALIASES[compact] ?? SORT_KEY_ALIASES[raw] ?? (compact as CatalogueSortKey); return { sortBy: sortKey, direction: direction ?? defaultDirectionForSort(sortKey), }; } export const normalizeSortPreference = normaliseSortPreference; export function normaliseCatalogueQuery( input: CatalogueQueryOptions | string = {}, legacy?: CatalogueQueryOptions, ): NormalisedCatalogueQuery { const legacyRecord = isRecord(legacy) ? legacy : {}; const inputRecord = isRecord(input) ? input : {}; const merged = { ...legacyRecord, ...inputRecord } as CatalogueQueryOptions; const search = typeof input === 'string' ? input : firstString(merged.search, merged.query, merged.searchTerm, merged.q); const sortInput = merged.sortBy ?? merged.sort ?? merged.orderBy ?? merged.order; const hasExplicitSort = sortInput !== null && sortInput !== undefined && !EMPTY_FILTER_VALUES.has(normaliseSearchText(sortInput)); const sortPreference = normaliseSortPreference( sortInput ?? 'name', merged.sortDirection ?? merged.direction ?? merged.orderDirection, ); const tagOperator = normaliseSearchText(merged.tagOperator) === 'any' ? 'any' : 'all'; const raw = { ...merged, search } as CatalogueQueryOptions; return { search, categories: normaliseFilterList( merged.category, merged.categories, merged.categoryId, merged.categoryFilter, ), difficulties: normaliseFilterList( merged.difficulty, merged.difficulties, merged.difficultyId, merged.difficultyFilter, merged.complexity, ), statuses: normaliseFilterList( merged.status, merged.statuses, merged.statusFilter, merged.availability, ), tags: normaliseFilterList(merged.tags, merged.tag, merged.tagFilter, merged.keywords), tagOperator, availableOnly: Boolean(merged.availableOnly ?? merged.onlyAvailable), favoritesOnly: Boolean( merged.favoritesOnly ?? merged.favouritesOnly ?? merged.favoriteOnly ?? merged.favouriteOnly ?? merged.bookmarkedOnly, ), favoriteIds: normaliseIdentitySet( merged.favoriteIds, merged.favouriteIds, merged.favorites, merged.favourites, merged.bookmarkIds, merged.bookmarks, ), sortBy: sortPreference.sortBy, sortDirection: sortPreference.direction, hasExplicitSort, raw, }; } export const normalizeCatalogueQuery = normaliseCatalogueQuery; function singulariseWord(word: string): string { if (word.endsWith('ies') && word.length > 4) { return `${word.slice(0, -3)}y`; } if (/(xes|ches|shes|sses|zzes)$/.test(word) && word.length > 5) { return word.slice(0, -2); } if (word.endsWith('s') && word.length > 3) { return word.slice(0, -1); } return word; } function singularise(value: string): string { return value.split(' ').map(singulariseWord).join(' '); } function filterTokensMatch(machineValue: string, selectedValue: string): boolean { if (machineValue === selectedValue) { return true; } const singularMachineValue = singularise(machineValue); const singularSelectedValue = singularise(selectedValue); return ( singularMachineValue === singularSelectedValue || (selectedValue.length >= 3 && machineValue.includes(selectedValue)) || (machineValue.length >= 3 && selectedValue.includes(machineValue)) ); } function matchesAnyFieldFilter( machine: unknown, keys: readonly string[], selectedValues: readonly string[], ): boolean { if (selectedValues.length === 0) { return true; } const machineValues = getComparableFieldValues(machine, keys); if (machineValues.length === 0) { return false; } return selectedValues.some((selectedValue) => machineValues.some((machineValue) => filterTokensMatch(machineValue, selectedValue)), ); } function matchesTagFilter( machine: unknown, selectedTags: readonly string[], operator: 'all' | 'any', ): boolean { if (selectedTags.length === 0) { return true; } const machineTags = getComparableFieldValues(machine, TAG_FIELD_KEYS); if (machineTags.length === 0) { return false; } const matchesTag = (selectedTag: string) => machineTags.some((machineTag) => filterTokensMatch(machineTag, selectedTag)); return operator === 'any' ? selectedTags.some(matchesTag) : selectedTags.every(matchesTag); } function isMachineAvailable(machine: unknown): boolean { const statusValues = getComparableFieldValues(machine, STATUS_FIELD_KEYS); if (statusValues.length === 0) { return true; } const statusText = statusValues.join(' '); return !UNAVAILABLE_STATUS_TOKENS.some((token) => statusText.includes(token)); } function hasFavouriteMatch(machine: unknown, favoriteIds: Set): boolean { if (favoriteIds.size === 0) { return false; } return getMachineIdentityCandidates(machine).some( (candidate) => favoriteIds.has(candidate) || favoriteIds.has(normaliseSearchText(candidate)), ); } function filterWithNormalisedQuery( machines: readonly TMachine[] | null | undefined, query: NormalisedCatalogueQuery, ): TMachine[] { const list = Array.isArray(machines) ? machines : []; return list.filter((machine) => { if (query.search && !machineMatchesSearch(machine, query.search)) { return false; } if (!matchesAnyFieldFilter(machine, CATEGORY_FIELD_KEYS, query.categories)) { return false; } if (!matchesAnyFieldFilter(machine, DIFFICULTY_FIELD_KEYS, query.difficulties)) { return false; } if (!matchesAnyFieldFilter(machine, STATUS_FIELD_KEYS, query.statuses)) { return false; } if (!matchesTagFilter(machine, query.tags, query.tagOperator)) { return false; } if (query.availableOnly && !isMachineAvailable(machine)) { return false; } if (query.favoritesOnly && !hasFavouriteMatch(machine, query.favoriteIds)) { return false; } return true; }); } export function filterMachines( machines: readonly TMachine[] | null | undefined, input: CatalogueQueryOptions | string = {}, legacy?: CatalogueQueryOptions, ): TMachine[] { return filterWithNormalisedQuery(machines, normaliseCatalogueQuery(input, legacy)); } export const filterCatalogueItems = filterMachines; function readFirstByKeys(record: UnknownRecord | undefined, keys: readonly string[]): unknown { if (!record) { return undefined; } for (const key of keys) { if (record[key] !== null && record[key] !== undefined) { return record[key]; } } return undefined; } function numberFrom(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'bigint') { return Number(value); } if (typeof value === 'boolean') { return value ? 1 : 0; } if (typeof value === 'string') { const match = value.replace(/,/g, '').match(/-?\d+(?:\.\d+)?/); return match ? Number(match[0]) : null; } if (isRecord(value)) { for (const key of ['value', 'score', 'count', 'rank', 'order', 'level', 'weight']) { const nestedNumber = numberFrom(value[key]); if (nestedNumber !== null) { return nestedNumber; } } } return null; } function timestampFrom(value: unknown): number | null { if (value instanceof Date) { return value.getTime(); } if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Date.parse(value); if (!Number.isNaN(parsed)) { return parsed; } return numberFrom(value); } if (isRecord(value)) { for (const key of ['date', 'value', 'updatedAt', 'createdAt', 'releasedAt', 'publishedAt']) { const nestedTimestamp = timestampFrom(value[key]); if (nestedTimestamp !== null) { return nestedTimestamp; } } } return null; } function getTimestampForFields(machine: unknown, keys: readonly string[]): number { const record = isRecord(machine) ? machine : undefined; for (const key of keys) { const timestamp = timestampFrom(record?.[key]); if (timestamp !== null) { return timestamp; } } return 0; } function getDifficultyWeight(machine: unknown): number { const record = isRecord(machine) ? machine : undefined; for (const key of DIFFICULTY_FIELD_KEYS) { const rawValue = record?.[key]; if (rawValue === null || rawValue === undefined) { continue; } const numericValue = numberFrom(rawValue); if (numericValue !== null && typeof rawValue !== 'string') { return numericValue; } const text = normaliseSearchText(rawValue); for (const [difficulty, weight] of Object.entries(DIFFICULTY_WEIGHTS)) { if (text.includes(difficulty)) { return weight; } } if (numericValue !== null) { return numericValue; } } return Number.MAX_SAFE_INTEGER; } function booleanScore(value: unknown): number | null { if (value === null || value === undefined) { return null; } if (typeof value === 'boolean') { return value ? 1 : 0; } if (typeof value === 'number') { return value > 0 ? 1 : 0; } const normalised = normaliseSearchText(value); if (['true', 'yes', 'featured', 'recommended'].includes(normalised)) { return 1; } if (['false', 'no', 'none'].includes(normalised)) { return 0; } return null; } function getFeaturedScore(machine: unknown): number { const record = isRecord(machine) ? machine : undefined; const score = booleanScore(readFirstByKeys(record, FEATURED_FIELD_KEYS)); return score ?? 0; } function getPopularityScore(machine: unknown): number { const record = isRecord(machine) ? machine : undefined; const popularity = numberFrom(readFirstByKeys(record, POPULARITY_FIELD_KEYS)); return popularity ?? getFeaturedScore(machine); } function getRecommendationScore(machine: unknown): number { return ( getFeaturedScore(machine) * 10_000 + (isMachineAvailable(machine) ? 1_000 : 0) + getPopularityScore(machine) + getTimestampForFields(machine, UPDATED_DATE_FIELD_KEYS) / 1_000_000_000_000 ); } export function getMachineSortValue( machine: unknown, sortBy: CatalogueSortKey, query?: { search?: string; query?: string }, ): string | number { const key = normaliseSearchText(sortBy).replace(/\s+/g, '-'); const record = isRecord(machine) ? machine : undefined; switch (key) { case 'relevance': { const search = firstString(query?.search, query?.query); return search ? scoreMachineForQuery(machine, search) : getRecommendationScore(machine); } case 'recommended': return getRecommendationScore(machine); case 'featured': return getFeaturedScore(machine); case 'name': case 'title': return getMachineTitle(machine); case 'category': return getComparableFieldValues(machine, CATEGORY_FIELD_KEYS)[0] ?? ''; case 'difficulty': return getDifficultyWeight(machine); case 'status': return getComparableFieldValues(machine, STATUS_FIELD_KEYS)[0] ?? ''; case 'newest': return getTimestampForFields(machine, CREATED_DATE_FIELD_KEYS); case 'updated': return getTimestampForFields(machine, UPDATED_DATE_FIELD_KEYS); case 'popularity': return getPopularityScore(machine); default: { const rawValue = record?.[String(sortBy)] ?? record?.[key]; return stringifySearchValue(rawValue ?? getMachineTitle(machine)); } } } function compareSortValues(left: string | number, right: string | number): number { if (typeof left === 'number' && typeof right === 'number') { if (left === right) { return 0; } return left < right ? -1 : 1; } return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base', }); } export function sortMachines( machines: readonly TMachine[] | null | undefined, sortByOrOptions: CatalogueSortKey | string | CatalogueQueryOptions = 'name', direction?: CatalogueSortDirection | string, ): TMachine[] { const list = Array.isArray(machines) ? [...machines] : []; const options = isRecord(sortByOrOptions) ? (sortByOrOptions as CatalogueQueryOptions) : ({ sortBy: sortByOrOptions, sortDirection: direction } as CatalogueQueryOptions); const query = normaliseCatalogueQuery(options); const sortBy = query.hasExplicitSort ? query.sortBy : query.search ? 'relevance' : query.sortBy; const sortDirection = query.hasExplicitSort ? query.sortDirection : defaultDirectionForSort(sortBy); return list .map((machine, index) => ({ index, machine })) .sort((left, right) => { const leftValue = getMachineSortValue(left.machine, sortBy, query); const rightValue = getMachineSortValue(right.machine, sortBy, query); let comparison = compareSortValues(leftValue, rightValue); if (sortDirection === 'desc') { comparison *= -1; } if (comparison !== 0) { return comparison; } const titleComparison = compareSortValues( getMachineTitle(left.machine), getMachineTitle(right.machine), ); return titleComparison || left.index - right.index; }) .map(({ machine }) => machine); } export const sortCatalogueItems = sortMachines; export function queryMachines( machines: readonly TMachine[] | null | undefined, input: CatalogueQueryOptions | string = {}, legacy?: CatalogueQueryOptions, ): TMachine[] { const query = normaliseCatalogueQuery(input, legacy); const filteredMachines = filterWithNormalisedQuery(machines, query); const sortBy = query.hasExplicitSort ? query.sortBy : query.search ? 'relevance' : query.sortBy; const sortDirection = query.hasExplicitSort ? query.sortDirection : defaultDirectionForSort(sortBy); return sortMachines(filteredMachines, { ...query.raw, search: query.search, sortBy, sortDirection, }); } export function filterAndSortMachines( machines: readonly TMachine[] | null | undefined, input: CatalogueQueryOptions | string = {}, legacy?: CatalogueQueryOptions, ): TMachine[] { return queryMachines(machines, input, legacy); } export const filterAndSortCatalogue = queryMachines; export function searchMachines( machines: readonly TMachine[] | null | undefined, searchOrOptions: string | CatalogueQueryOptions = '', options: CatalogueQueryOptions = {}, ): TMachine[] { if (isRecord(searchOrOptions)) { return queryMachines(machines, searchOrOptions as CatalogueQueryOptions); } return queryMachines(machines, { ...options, search: searchOrOptions }); } export const searchCatalogue = searchMachines; export const catalogueSearch = searchMachines; export function createMachineSearchIndex( machines: readonly TMachine[] | null | undefined, ): MachineSearchIndexEntry[] { const list = Array.isArray(machines) ? machines : []; return list.map((machine) => { const text = getSearchableMachineText(machine); const routeKey = getMachineRouteKey(machine); return { id: routeKey, slug: routeKey, title: getMachineTitle(machine), text, tokens: tokeniseSearchText(text), machine, }; }); } export function rankMachineSearch( machines: readonly TMachine[] | null | undefined, query: unknown, ): RankedMachine[] { const list = Array.isArray(machines) ? machines : []; const normalisedQuery = normaliseSearchText(query); if (!normalisedQuery) { return list.map((machine, index) => ({ machine, score: 0, index })); } return list .map((machine, index) => ({ machine, index, score: scoreMachineForQuery(machine, query), })) .filter(({ score }) => score > Number.NEGATIVE_INFINITY) .sort((left, right) => { if (left.score !== right.score) { return right.score - left.score; } const titleComparison = compareSortValues( getMachineTitle(left.machine), getMachineTitle(right.machine), ); return titleComparison || left.index - right.index; }); } export const rankedMachineSearch = rankMachineSearch; export const rankMachines = rankMachineSearch; export default { CATALOGUE_SORT_OPTIONS, SORT_OPTIONS, createMachineSearchIndex, createSearchableText, defaultDirectionForSort, filterAndSortCatalogue, filterAndSortMachines, filterCatalogueItems, filterMachines, getMachineRouteKey, getMachineSearchFields, getMachineSearchText, getMachineSortValue, getMachineTitle, getSearchableMachineText, machineMatchesSearch, matchesMachineQuery, matchesSearch, normaliseCatalogueQuery, normaliseFilterList, normaliseSearchTerm, normaliseSearchText, normaliseSortPreference, normalizeCatalogueQuery, normalizeFilterList, normalizeSearchTerm, normalizeSearchText, normalizeSortPreference, queryMachines, rankMachineSearch, rankMachines, rankedMachineSearch, scoreMachineForQuery, searchCatalogue, searchMachines, sortCatalogueItems, sortMachines, tokeniseSearchQuery, tokeniseSearchText, tokenizeSearchQuery, tokenizeSearchText, };