mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
bring over desktop's updated fuzzy search
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 variant="body" style={styles.highlight}>
|
||||
{text.slice(substringIndex, substringIndex + normalizedQuery.length)}
|
||||
</Text>
|
||||
{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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+263
-86
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user