import type { SearchableFieldData } from "./components/SearchResultsView"; type SearchableFieldWithKeywords = SearchableFieldData & { keywords?: string[]; }; /** * Normalize text for search matching (lowercase, trim, collapse whitespace). */ export function normalizeSearchText(text: string): string { return text.toLowerCase().trim().replace(/\s+/g, " "); } /** * Tokenize a query into individual words for multi-word matching. */ export function tokenizeSearchText(text: string): string[] { return normalizeSearchText(text) .split(/\s+/) .filter((token) => token.length > 0); } /** * Calculate a relevance score for a field based on query match quality. * Higher score = better match. */ export function calculateSearchRelevance( query: string, field: SearchableFieldWithKeywords, ): number { const normalizedQuery = normalizeSearchText(query); const queryTokens = tokenizeSearchText(query); if (!normalizedQuery || queryTokens.length === 0) { return 0; } let score = 0; const normalizedLabel = normalizeSearchText(field.label); if (normalizedLabel === normalizedQuery) { score += 100; } else if (normalizedLabel.startsWith(normalizedQuery)) { score += 75; } else if (normalizedLabel.includes(normalizedQuery)) { score += 50; } const allTexts = [ field.label, field.description, field.section, ...(field.keywords || []), ] .filter(Boolean) .map((text) => normalizeSearchText(text as string)); const combinedText = allTexts.join(" "); const allTokensMatch = queryTokens.every((token) => combinedText.includes(token), ); if (!allTokensMatch) { return 0; } if (field.description) { const normalizedDescription = normalizeSearchText(field.description); if (normalizedDescription.includes(normalizedQuery)) { score += 25; } else { const matchingTokens = queryTokens.filter((token) => normalizedDescription.includes(token), ); score += matchingTokens.length * 5; } } if (field.section) { const normalizedSection = normalizeSearchText(field.section); if (normalizedSection.includes(normalizedQuery)) { score += 20; } } if (field.keywords && field.keywords.length > 0) { for (const keyword of field.keywords) { const normalizedKeyword = normalizeSearchText(keyword); if (normalizedKeyword === normalizedQuery) { score += 40; } else if (normalizedKeyword.includes(normalizedQuery)) { score += 15; } else { for (const token of queryTokens) { if (normalizedKeyword.includes(token)) { score += 8; } } } } } for (const text of allTexts) { if (queryTokens.every((token) => text.includes(token))) { score += 10; break; } } return score; }