/** * Fuzzy matching utilities. * Matches if all query characters appear in order (not necessarily consecutive). * Lower score = better match. */ export interface FuzzyMatch { matches: boolean; score: number; } export function fuzzyMatch(query: string, text: string): FuzzyMatch { const queryLower = query.toLowerCase(); const textLower = text.toLowerCase(); if (queryLower.length === 0) { return { matches: true, score: 0 }; } if (queryLower.length > textLower.length) { return { matches: false, score: 0 }; } let queryIndex = 0; let score = 0; let lastMatchIndex = -1; let consecutiveMatches = 0; for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { if (textLower[i] === queryLower[queryIndex]) { const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); // Reward consecutive matches if (lastMatchIndex === i - 1) { consecutiveMatches++; score -= consecutiveMatches * 5; } else { consecutiveMatches = 0; // Penalize gaps if (lastMatchIndex >= 0) { score += (i - lastMatchIndex - 1) * 2; } } // Reward word boundary matches if (isWordBoundary) { score -= 10; } // Slight penalty for later matches score += i * 0.1; lastMatchIndex = i; queryIndex++; } } if (queryIndex < queryLower.length) { return { matches: false, score: 0 }; } return { matches: true, score }; } /** * Filter and sort items by fuzzy match quality (best matches first). * Supports space-separated tokens: all tokens must match. */ export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { if (!query.trim()) { return items; } const tokens = query .trim() .split(/\s+/) .filter((t) => t.length > 0); if (tokens.length === 0) { return items; } const results: { item: T; totalScore: number }[] = []; for (const item of items) { const text = getText(item); let totalScore = 0; let allMatch = true; for (const token of tokens) { const match = fuzzyMatch(token, text); if (match.matches) { totalScore += match.score; } else { allMatch = false; break; } } if (allMatch) { results.push({ item, totalScore }); } } results.sort((a, b) => a.totalScore - b.totalScore); return results.map((r) => r.item); }