57 lines
2.3 KiB
TypeScript
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 };
|
|
}
|