From 6fb2da03b227e7a711b6519e0b05bb4b2bb6d8b2 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:38:38 -0400 Subject: [PATCH] bring over desktop's updated fuzzy search --- package.json | 1 + scripts/run-release-tests.mjs | 1 + src/components/search/QuickSearchOverlay.tsx | 67 ++-- src/lib/fuzzySearch.test.mts | 123 +++++++ src/lib/fuzzySearch.ts | 349 ++++++++++++++----- 5 files changed, 423 insertions(+), 118 deletions(-) create mode 100644 src/lib/fuzzySearch.test.mts diff --git a/package.json b/package.json index 873fa9d..a136476 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/cacheInvalidation.test.mts", + "test:fuzzy-search": "node --experimental-strip-types --test src/lib/fuzzySearch.test.mts", "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/components/player/artistCreditVisibility.test.mts src/playback/playbackTargetPresentation.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs index 8d13163..f61bb19 100644 --- a/scripts/run-release-tests.mjs +++ b/scripts/run-release-tests.mjs @@ -18,6 +18,7 @@ const TEST_SCRIPTS = [ 'test:lyrics', 'test:sleep', 'test:troubleshooting', + 'test:fuzzy-search', 'test:settings-search', 'test:now-playing-layout', 'test:memory-lifecycle', diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index c1da68b..df50b98 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -36,7 +36,7 @@ import { artworkUri, trackArtworkThumbSource } from '@/library/artwork'; -import { multiFieldScore, MIN_SCORE_THRESHOLD } from '@/lib/fuzzySearch'; +import { findFuzzyMatch, multiFieldScore } from '@/lib/fuzzySearch'; import { formatDuration } from '@/lib/format'; import { playHaptic } from '@/lib/haptics'; import { useLibraryStore } from '@/stores/libraryStore'; @@ -332,7 +332,7 @@ function plural(count: number, noun: string): string { } function scoreOrNull(score: number | null): score is number { - return score !== null && score >= MIN_SCORE_THRESHOLD; + return score !== null; } function playlistFromRow(playlist: Playlist): SearchPlaylist { @@ -413,42 +413,45 @@ function resultIcon(result: SearchResult): IconName { function HighlightedLabel({ text, query }: { text: string; query: string }) { const styles = useStyles(); - const normalizedText = text.toLocaleLowerCase(); - const normalizedQuery = query.toLocaleLowerCase().trim(); - - if (!normalizedQuery) { - return <>{text}; - } - - const substringIndex = normalizedText.indexOf(normalizedQuery); - if (substringIndex >= 0) { - return ( - <> - {text.slice(0, substringIndex)} - - {text.slice(substringIndex, substringIndex + normalizedQuery.length)} - - {text.slice(substringIndex + normalizedQuery.length)} - - ); - } + const match = findFuzzyMatch(query, text); + if (!match || match.indices.length === 0) return <>{text}; + const matchedIndices = [...new Set(match.indices)].sort((left, right) => left - right); const parts: { text: string; highlighted: boolean; key: string }[] = []; - let queryIndex = 0; - let lastPushed = 0; + let cursor = 0; + let groupStart = matchedIndices[0]; + let groupEnd = groupStart + 1; - for (let i = 0; i < text.length && queryIndex < normalizedQuery.length; i += 1) { - if (text[i].toLocaleLowerCase() !== normalizedQuery[queryIndex]) continue; - if (i > lastPushed) { - parts.push({ text: text.slice(lastPushed, i), highlighted: false, key: `plain-${i}` }); + const pushGroup = () => { + if (groupStart > cursor) { + parts.push({ + text: text.slice(cursor, groupStart), + highlighted: false, + key: `plain-${cursor}`, + }); } - parts.push({ text: text[i], highlighted: true, key: `mark-${i}` }); - queryIndex += 1; - lastPushed = i + 1; + parts.push({ + text: text.slice(groupStart, groupEnd), + highlighted: true, + key: `mark-${groupStart}`, + }); + cursor = groupEnd; + }; + + for (let index = 1; index < matchedIndices.length; index += 1) { + const textIndex = matchedIndices[index]; + if (textIndex === groupEnd) { + groupEnd += 1; + continue; + } + pushGroup(); + groupStart = textIndex; + groupEnd = textIndex + 1; } - if (lastPushed < text.length) { - parts.push({ text: text.slice(lastPushed), highlighted: false, key: 'tail' }); + pushGroup(); + if (cursor < text.length) { + parts.push({ text: text.slice(cursor), highlighted: false, key: 'tail' }); } return ( diff --git a/src/lib/fuzzySearch.test.mts b/src/lib/fuzzySearch.test.mts new file mode 100644 index 0000000..f15a595 --- /dev/null +++ b/src/lib/fuzzySearch.test.mts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + findFuzzyMatch, + multiFieldScore, + type FuzzyMatch, + type FuzzyMatchKind, +} from './fuzzySearch.ts'; + +function requireMatch( + query: string, + candidate: string, + kind: FuzzyMatchKind +): FuzzyMatch { + const match = findFuzzyMatch(query, candidate); + if (!match) assert.fail(`Expected ${JSON.stringify(query)} to match ${JSON.stringify(candidate)}`); + assert.equal(match.kind, kind); + return match; +} + +test('classifies every accepted match shape in relevance order', () => { + const exact = requireMatch('Radiohead', 'Radiohead', 'exact'); + const prefix = requireMatch('Radio', 'Radiohead', 'prefix'); + const wordPrefix = requireMatch('Radio', 'The Radio Dept.', 'word-prefix'); + const substring = requireMatch('radio', 'Piradio Signal', 'substring'); + const initialism = requireMatch('rhc', 'Red Hot Chili Peppers', 'initialism'); + const compact = requireMatch('rdio', 'Radiohead', 'compact'); + + assert.ok(exact.score > prefix.score); + assert.ok(prefix.score > wordPrefix.score); + assert.ok(wordPrefix.score > substring.score); + assert.ok(substring.score > initialism.score); + assert.ok(initialism.score > compact.score); +}); + +test('keeps short queries strict while accepting consecutive initials', () => { + assert.equal(findFuzzyMatch('rd', 'Radiohead'), null); + assert.equal(findFuzzyMatch('io', 'Radiohead'), null); + requireMatch('ra', 'Radiohead', 'prefix'); + requireMatch('de', 'The Department', 'word-prefix'); + requireMatch('rh', 'Red Hot Chili Peppers', 'initialism'); + assert.equal(findFuzzyMatch('rhc', 'Red Hot Spicy Chili Peppers'), null); +}); + +test('bounds compact matches and rejects scattered, typo, incomplete, and multiword matches', () => { + requireMatch('rdio', 'Radiohead', 'compact'); + assert.equal(findFuzzyMatch('rdio', 'Raaaaaaaadio'), null); + assert.equal(findFuzzyMatch('rhd', 'Radiohead'), null); + assert.equal(findFuzzyMatch('the', 'Everything in Its Right Place'), null); + assert.equal(findFuzzyMatch('kid', 'Knights of Cydonia'), null); + assert.equal(findFuzzyMatch('love', 'Long Drive Home'), null); + assert.equal(findFuzzyMatch('raido', 'Radiohead'), null); + assert.equal(findFuzzyMatch('radioz', 'Radiohead'), null); + assert.equal(findFuzzyMatch('r d', 'Radiohead'), null); +}); + +test('normalizes case and repeated whitespace without broadening eligibility', () => { + requireMatch(' RADIO DEPT ', 'Radio Dept', 'exact'); + assert.equal(findFuzzyMatch('', 'Radiohead'), null); + assert.equal(findFuzzyMatch('radio', ''), null); +}); + +test('ranks match class before field weight and uses the strongest eligible field', () => { + const exactLowWeight = multiFieldScore('radio', [ + { value: 'Radio', weight: 0.1 }, + ]); + const prefixHighWeight = multiFieldScore('radio', [ + { value: 'Radiohead', weight: 9 }, + ]); + + assert.notEqual(exactLowWeight, null); + assert.notEqual(prefixHighWeight, null); + assert.ok((exactLowWeight ?? 0) > (prefixHighWeight ?? 0)); + + const strongestField = multiFieldScore('radio', [ + { value: 'The Radio Dept.', weight: 2 }, + { value: 'Radiohead', weight: 1 }, + ]); + assert.equal(strongestField, multiFieldScore('radio', [ + { value: 'Radiohead', weight: 1 }, + ])); +}); + +test('handles nullable fields and does not let weights revive rejected fields', () => { + assert.equal(multiFieldScore('radio', [ + { value: null, weight: 999 }, + { value: undefined, weight: 999 }, + { value: 'Radio', weight: 1 }, + ]), multiFieldScore('radio', [{ value: 'Radio', weight: 1 }])); + + assert.equal(multiFieldScore('kid', [ + { value: 'Knights of Cydonia', weight: 999 }, + ]), null); +}); + +test('prefers compact, early, shorter candidates within a match class', () => { + const compact = requireMatch('rdio', 'Radiohead', 'compact').score; + const spread = requireMatch('rdio', 'Raxdiohead', 'compact').score; + const earlier = requireMatch('radio', 'The Radio Dept.', 'word-prefix').score; + const later = requireMatch('radio', 'Music by Radio Dept.', 'word-prefix').score; + const shorter = requireMatch('radio', 'Radiohead', 'prefix').score; + const longer = requireMatch('radio', 'Radiotelegraph', 'prefix').score; + + assert.ok(compact > spread); + assert.ok(earlier > later); + assert.ok(shorter > longer); + assert.equal( + multiFieldScore('radio', [{ value: 'Radiohead', weight: 1 }]), + multiFieldScore('radio', [{ value: 'Radiohead', weight: 1 }]) + ); +}); + +test('returns exact original indices for literal, initialism, and compact highlights', () => { + assert.deepEqual(requireMatch('radio', 'The Radio Dept.', 'word-prefix').indices, [4, 5, 6, 7, 8]); + assert.deepEqual(requireMatch('rhc', 'Red Hot Chili Peppers', 'initialism').indices, [0, 4, 8]); + assert.deepEqual(requireMatch('rdio', 'Radiohead', 'compact').indices, [0, 2, 3, 4]); +}); + +test('returns no indices for rejected or incomplete highlight queries', () => { + assert.equal(findFuzzyMatch('rhd', 'Radiohead'), null); + assert.equal(findFuzzyMatch('radioz', 'Radiohead'), null); + assert.equal(findFuzzyMatch('radio', 'Kid A'), null); +}); diff --git a/src/lib/fuzzySearch.ts b/src/lib/fuzzySearch.ts index c4ef1be..7a558da 100644 --- a/src/lib/fuzzySearch.ts +++ b/src/lib/fuzzySearch.ts @@ -17,6 +17,76 @@ const WORD_BOUNDARY_SEPARATORS = new Set([ '"', "'", ]); +const WHITESPACE_PATTERN = /\s/u; + +const MATCH_KIND_RANK = { + compact: 1, + initialism: 2, + substring: 3, + 'word-prefix': 4, + prefix: 5, + exact: 6, +} as const; + +const MATCH_KIND_SCORE_SCALE = 1_000_000_000; +const FIELD_WEIGHT_SCORE_SCALE = 1_000_000; +const MAX_FIELD_WEIGHT_RANK = 999; +const MAX_PROXIMITY_COMPONENT = 99; + +export type FuzzyMatchKind = keyof typeof MATCH_KIND_RANK; + +export interface FuzzyMatch { + kind: FuzzyMatchKind; + score: number; + indices: number[]; + span: number; + startIndex: number; +} + +interface NormalizedSearchValue { + value: string; + originalIndices: number[]; +} + +interface DetailedFuzzyMatch { + kind: FuzzyMatchKind; + score: number; + normalizedIndices: number[]; + span: number; + startIndex: number; + normalizedCandidateLength: number; + normalizedQueryLength: number; +} + +function normalizeSearchValueWithIndices(value: string): NormalizedSearchValue { + let normalized = ''; + const originalIndices: number[] = []; + let pendingWhitespaceIndex = -1; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (WHITESPACE_PATTERN.test(character)) { + if (normalized.length > 0 && pendingWhitespaceIndex < 0) { + pendingWhitespaceIndex = index; + } + continue; + } + + if (pendingWhitespaceIndex >= 0) { + normalized += ' '; + originalIndices.push(pendingWhitespaceIndex); + pendingWhitespaceIndex = -1; + } + + const lowerCharacter = character.toLocaleLowerCase(); + normalized += lowerCharacter; + for (let lowerIndex = 0; lowerIndex < lowerCharacter.length; lowerIndex += 1) { + originalIndices.push(index); + } + } + + return { value: normalized, originalIndices }; +} function normalizeSearchValue(value: string): string { return value.toLocaleLowerCase().trim().replace(/\s+/g, ' '); @@ -27,61 +97,188 @@ function isWordBoundary(value: string, index: number): boolean { return WORD_BOUNDARY_SEPARATORS.has(value[index - 1]); } -export function fuzzyScore(queryInput: string, candidateInput: string): number | null { - const query = normalizeSearchValue(queryInput); - const candidate = normalizeSearchValue(candidateInput); +function isWordStart(value: string, index: number): boolean { + return !WORD_BOUNDARY_SEPARATORS.has(value[index]) && isWordBoundary(value, index); +} +function isSingleToken(value: string): boolean { + for (const character of value) { + if (WORD_BOUNDARY_SEPARATORS.has(character)) return false; + } + return true; +} + +function buildRange(start: number, length: number): number[] { + return Array.from({ length }, (_, offset) => start + offset); +} + +function proximityScore( + normalizedQueryLength: number, + normalizedCandidateLength: number, + startIndex: number, + span: number +): number { + const compactnessRank = MAX_PROXIMITY_COMPONENT - Math.min( + MAX_PROXIMITY_COMPONENT, + Math.max(0, span - normalizedQueryLength) + ); + const startRank = MAX_PROXIMITY_COMPONENT - Math.min( + MAX_PROXIMITY_COMPONENT, + Math.max(0, startIndex) + ); + const lengthRank = MAX_PROXIMITY_COMPONENT - Math.min( + MAX_PROXIMITY_COMPONENT, + Math.max(0, normalizedCandidateLength - normalizedQueryLength) + ); + + return (compactnessRank * 10_000) + (startRank * 100) + lengthRank; +} + +function createMatch( + kind: FuzzyMatchKind, + normalizedIndices: number[], + normalizedCandidate: string, + normalizedQueryLength: number +): DetailedFuzzyMatch { + const startIndex = normalizedIndices[0]; + const endIndex = normalizedIndices[normalizedIndices.length - 1]; + const span = endIndex - startIndex + 1; + const score = (MATCH_KIND_RANK[kind] * MATCH_KIND_SCORE_SCALE) + proximityScore( + normalizedQueryLength, + normalizedCandidate.length, + startIndex, + span + ); + + return { + kind, + score, + normalizedIndices, + span, + startIndex, + normalizedCandidateLength: normalizedCandidate.length, + normalizedQueryLength, + }; +} + +function findConsecutiveInitials(query: string, candidate: string): number[] | null { + const wordStarts: number[] = []; + for (let index = 0; index < candidate.length; index += 1) { + if (isWordStart(candidate, index)) wordStarts.push(index); + } + + for (let start = 0; start <= wordStarts.length - query.length; start += 1) { + const indices = wordStarts.slice(start, start + query.length); + if (indices.every((candidateIndex, queryIndex) => candidate[candidateIndex] === query[queryIndex])) { + return indices; + } + } + + return null; +} + +function findCompactSubsequence(query: string, candidate: string): number[] | null { + const maximumSpan = query.length * 2; + let bestIndices: number[] | null = null; + + for (let startIndex = 0; startIndex < candidate.length; startIndex += 1) { + if (candidate[startIndex] !== query[0] || !isWordStart(candidate, startIndex)) continue; + + const indices = [startIndex]; + let queryIndex = 1; + const endExclusive = Math.min(candidate.length, startIndex + maximumSpan); + + for (let candidateIndex = startIndex + 1; candidateIndex < endExclusive; candidateIndex += 1) { + if (candidate[candidateIndex] !== query[queryIndex]) continue; + indices.push(candidateIndex); + queryIndex += 1; + if (queryIndex === query.length) break; + } + + if (queryIndex !== query.length) continue; + if (!bestIndices) { + bestIndices = indices; + continue; + } + + const span = indices[indices.length - 1] - indices[0] + 1; + const bestSpan = bestIndices[bestIndices.length - 1] - bestIndices[0] + 1; + if (span < bestSpan || (span === bestSpan && indices[0] < bestIndices[0])) { + bestIndices = indices; + } + } + + return bestIndices; +} + +function findNormalizedFuzzyMatch(query: string, candidate: string): DetailedFuzzyMatch | null { if (!query || !candidate) return null; - let queryIndex = 0; - let firstMatchIndex = -1; - let lastMatchIndex = -1; - let previousMatchIndex = -2; - let contiguousMatches = 0; - let boundaryMatches = 0; - let score = 0; - - for (let candidateIndex = 0; candidateIndex < candidate.length; candidateIndex += 1) { - if (candidate[candidateIndex] !== query[queryIndex]) continue; - - if (firstMatchIndex === -1) { - firstMatchIndex = candidateIndex; - } - - const contiguous = candidateIndex === previousMatchIndex + 1; - const boundary = isWordBoundary(candidate, candidateIndex); - - if (queryIndex === 0) { - if (candidateIndex === 0) { - score += 20; - } else if (boundary) { - score += 12; - } - } - - if (boundary) boundaryMatches += 1; - if (contiguous) contiguousMatches += 1; - - previousMatchIndex = candidateIndex; - lastMatchIndex = candidateIndex; - queryIndex += 1; - - if (queryIndex === query.length) break; + if (candidate === query) { + return createMatch('exact', buildRange(0, query.length), candidate, query.length); } - if (queryIndex !== query.length || firstMatchIndex < 0 || lastMatchIndex < 0) { - return null; + if (candidate.startsWith(query)) { + return createMatch('prefix', buildRange(0, query.length), candidate, query.length); } - const span = lastMatchIndex - firstMatchIndex + 1; - score += query.length * 8; - score += contiguousMatches * 5; - score += boundaryMatches * 4; - score += Math.max(0, 16 - span); - score += Math.max(0, 10 - firstMatchIndex); - score += Math.max(0, 10 - (candidate.length - query.length)); + for (let index = 1; index <= candidate.length - query.length; index += 1) { + if (!isWordStart(candidate, index) || !candidate.startsWith(query, index)) continue; + return createMatch('word-prefix', buildRange(index, query.length), candidate, query.length); + } - return score; + if (query.length >= 3) { + const substringIndex = candidate.indexOf(query); + if (substringIndex >= 0) { + return createMatch('substring', buildRange(substringIndex, query.length), candidate, query.length); + } + } + + if (!isSingleToken(query)) return null; + + if (query.length >= 2) { + const initialIndices = findConsecutiveInitials(query, candidate); + if (initialIndices) { + return createMatch('initialism', initialIndices, candidate, query.length); + } + } + + if (query.length >= 3) { + const compactIndices = findCompactSubsequence(query, candidate); + if (compactIndices) { + return createMatch('compact', compactIndices, candidate, query.length); + } + } + + return null; +} + +function findDetailedFuzzyMatch(queryInput: string, candidateInput: string): DetailedFuzzyMatch | null { + return findNormalizedFuzzyMatch( + normalizeSearchValue(queryInput), + normalizeSearchValue(candidateInput) + ); +} + +export function findFuzzyMatch(queryInput: string, candidateInput: string): FuzzyMatch | null { + const query = normalizeSearchValue(queryInput); + const candidate = normalizeSearchValueWithIndices(candidateInput); + const match = findNormalizedFuzzyMatch(query, candidate.value); + if (!match) return null; + const indices = match.normalizedIndices + .map((index) => candidate.originalIndices[index]) + .filter((index, position, values) => position === 0 || index !== values[position - 1]); + return { + kind: match.kind, + score: match.score, + indices, + span: match.span, + startIndex: match.startIndex, + }; +} + +export function fuzzyScore(queryInput: string, candidateInput: string): number | null { + return findDetailedFuzzyMatch(queryInput, candidateInput)?.score ?? null; } export interface FieldDef { @@ -89,7 +286,19 @@ export interface FieldDef { weight: number; } -export const MIN_SCORE_THRESHOLD = 25; +function weightedMatchScore(match: DetailedFuzzyMatch, weight: number): number { + const weightRank = Number.isFinite(weight) + ? Math.max(0, Math.min(MAX_FIELD_WEIGHT_RANK, Math.round(weight * 100))) + : 0; + const baseKindScore = MATCH_KIND_RANK[match.kind] * MATCH_KIND_SCORE_SCALE; + const proximity = proximityScore( + match.normalizedQueryLength, + match.normalizedCandidateLength, + match.startIndex, + match.span + ); + return baseKindScore + (weightRank * FIELD_WEIGHT_SCORE_SCALE) + proximity; +} export function multiFieldScore(queryInput: string, fields: FieldDef[]): number | null { const normalizedQuery = normalizeSearchValue(queryInput); @@ -98,48 +307,16 @@ export function multiFieldScore(queryInput: string, fields: FieldDef[]): number let bestScore: number | null = null; for (const field of fields) { - const value = field.value ?? ''; - const normalizedValue = normalizeSearchValue(value); - if (!normalizedValue) continue; - - let fieldScore = fuzzyScore(queryInput, value); - if (fieldScore === null) continue; - - if (normalizedValue === normalizedQuery) { - fieldScore += 60; - } - - if (normalizedValue.includes(normalizedQuery)) { - fieldScore += 30; - } - - if (normalizedValue.startsWith(normalizedQuery)) { - fieldScore += 20; - } - - const words = normalizedValue.split(/\s+/); - if (words.some((word) => word.startsWith(normalizedQuery))) { - fieldScore += 15; - } - - fieldScore = Math.round(fieldScore * field.weight); - + const match = findNormalizedFuzzyMatch( + normalizedQuery, + normalizeSearchValue(field.value ?? '') + ); + if (!match) continue; + const fieldScore = weightedMatchScore(match, field.weight); if (bestScore === null || fieldScore > bestScore) { bestScore = fieldScore; } } - if (bestScore === null) return null; - - if (normalizedQuery.length <= 2) { - const hasStrictMatch = fields.some((field) => { - const normalizedValue = normalizeSearchValue(field.value ?? ''); - if (!normalizedValue) return false; - if (normalizedValue.startsWith(normalizedQuery)) return true; - return normalizedValue.split(/\s+/).some((word) => word.startsWith(normalizedQuery)); - }); - if (!hasStrictMatch) return null; - } - return bestScore; }