Files
buzz-sheet/lib/search.ts
T
gunshiz 92b57e0ed3
CI / Verify (push) Failing after 58s
CI / Build immutable images and deploy (push) Has been skipped
feat : add fuzzy matching for searching
2026-09-12 16:07:16 +07:00

57 lines
2.3 KiB
TypeScript

export interface SearchResult<T> {
items: T[];
fuzzy: boolean;
}
export function normalizeSearch(value: string): string {
return value.normalize("NFC").trim().toLocaleLowerCase();
}
// Bounded optimal-string-alignment distance, including adjacent transpositions.
function editDistance(left: string[], right: string[], limit: number): number {
if (Math.abs(left.length - right.length) > limit) return limit + 1;
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
let beforePrevious = previous;
for (let i = 1; i <= left.length; i++) {
const current = Array<number>(right.length + 1).fill(limit + 1);
current[0] = i;
for (let j = Math.max(1, i - limit); j <= Math.min(right.length, i + limit); j++) {
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1,
previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
current[j] = Math.min(current[j], beforePrevious[j - 2] + 1);
}
}
beforePrevious = previous;
previous = current;
}
return previous[right.length];
}
export function searchItems<T>(
items: readonly T[],
query: string,
directText: (item: T) => string,
names: (item: T) => readonly string[],
): SearchResult<T> {
const normalized = normalizeSearch(query);
if (!normalized) return { items: [...items], fuzzy: false };
const direct = items.filter((item) => normalizeSearch(directText(item)).includes(normalized));
if (direct.length) return { items: direct, fuzzy: false };
const characters = Array.from(normalized);
if (characters.length < 4) return { items: [], fuzzy: false };
const limit = Math.min(characters.length < 8 ? 1 : 2, Math.floor(characters.length / 4));
const matches = items.map((item, index) => {
let distance = limit + 1;
for (const name of names(item)) {
const candidate = normalizeSearch(name);
for (const part of new Set([candidate, ...candidate.split(/\s+/u)])) {
distance = Math.min(distance, editDistance(characters, Array.from(part), limit));
}
}
return { item, index, distance };
}).filter(({ distance }) => distance <= limit)
.sort((a, b) => a.distance - b.distance || a.index - b.index);
return { items: matches.map(({ item }) => item), fuzzy: matches.length > 0 };
}