core: reuse fuzzy search logic

This commit is contained in:
Abdullah Atta
2025-02-21 12:16:47 +05:00
parent 80a0b75887
commit 9dce39b32d
3 changed files with 78 additions and 86 deletions

View File

@@ -35,6 +35,7 @@ import { logger } from "../logger.js";
import { rebuildSearchIndex } from "../database/fts.js"; import { rebuildSearchIndex } from "../database/fts.js";
import { transformQuery } from "../utils/query-transformer.js"; import { transformQuery } from "../utils/query-transformer.js";
import { getSortSelectors } from "../utils/grouping.js"; import { getSortSelectors } from "../utils/grouping.js";
import { fuzzy } from "../utils/fuzzy.js";
type SearchResults<T> = { type SearchResults<T> = {
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>; sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
@@ -210,63 +211,17 @@ export default class Lookup {
suffix?: string; suffix?: string;
} = {} } = {}
) { ) {
const results: Map<
string,
{
item: T;
score: number;
}
> = new Map();
const columns = fields.map((f) => f.column); const columns = fields.map((f) => f.column);
const items = await selector.fields(columns).items(); const items = await selector.fields(columns).items();
for (const item of items) { return fuzzy(
if (options.limit && results.size >= options.limit) break; query,
items,
for (const field of fields) { Object.fromEntries(
if (field.ignore) continue; fields.filter((f) => !f.ignore).map((f) => [f.name, f.weight || 1])
) as Record<keyof T, number>,
const result = match(query, `${item[field.name]}`); options
if (!result.match) continue; );
const oldMatch = results.get(item.id);
if (options.suffix && options.prefix) {
item[field.name] = surround(`${item[field.name]}`, {
suffix: options.suffix,
prefix: options.prefix,
result
}) as T[keyof T];
}
if (oldMatch) {
oldMatch.score += result.score * (field.weight || 1);
} else {
results.set(item.id, {
item,
score: result.score * (field.weight || 1)
});
}
}
}
selector.fields([]);
if (results.size === 0) return [];
const sorted = Array.from(results.entries());
if (!options.sortOptions)
// || sortOptions.sortBy === "relevance")
sorted.sort(
// sortOptions?.sortDirection === "desc"
// ? (a, b) => a[1] - b[1]
// :
(a, b) => b[1].score - a[1].score
);
return sorted.map((item) => ({
id: item[0],
score: item[1].score,
item: item[1].item
}));
} }
private toSearchResults<T extends Item>( private toSearchResults<T extends Item>(

View File

@@ -24,52 +24,69 @@ describe("lookup.fuzzy", () => {
test("should sort items by score", () => { test("should sort items by score", () => {
const items = [ const items = [
{ {
id: "1",
title: "system" title: "system"
}, },
{ {
id: "2",
title: "hello" title: "hello"
}, },
{ {
id: "3",
title: "items" title: "items"
} }
]; ];
const query = "ems"; const query = "ems";
expect(fuzzy(query, items, "title")).toStrictEqual([items[2]]); expect(fuzzy(query, items, { title: 1 })).toStrictEqual([items[2]]);
}); });
describe("opts.prefix", () => { describe("opts.prefix", () => {
test("should prefix matched field with provided value when given", () => { test("should prefix matched field with provided value when given", () => {
const items = [ const items = [
{ {
id: "1",
title: "hello" title: "hello"
}, },
{ {
id: "2",
title: "world" title: "world"
} }
]; ];
const query = "d"; const query = "d";
expect( expect(
fuzzy(query, items, "title", { fuzzy(
prefix: "prefix-" query,
}) items,
).toStrictEqual([{ title: "worlprefix-d" }]); { title: 1 },
{
prefix: "prefix-"
}
)
).toStrictEqual([{ id: "2", title: "worlprefix-d" }]);
}); });
}); });
describe("opt.suffix", () => { describe("opt.suffix", () => {
test("should suffix matched field with provided value when given", () => { test("should suffix matched field with provided value when given", () => {
const items = [ const items = [
{ {
id: "1",
title: "hello" title: "hello"
}, },
{ {
id: "2",
title: "world" title: "world"
} }
]; ];
const query = "llo"; const query = "llo";
expect( expect(
fuzzy(query, items, "title", { fuzzy(
suffix: "-suffix" query,
}) items,
).toStrictEqual([{ title: "hello-suffix" }]); { title: 1 },
{
suffix: "-suffix"
}
)
).toStrictEqual([{ id: "1", title: "hello-suffix" }]);
}); });
}); });
}); });

View File

@@ -18,38 +18,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { match, surround } from "fuzzyjs"; import { match, surround } from "fuzzyjs";
import { clone } from "./clone";
export function fuzzy<T>( export function fuzzy<T extends { id: string }>(
query: string, query: string,
items: T[], items: T[],
key: keyof T, fields: Partial<Record<keyof T, number>>,
opts?: { options: {
limit?: number;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
} } = {}
): T[] { ): T[] {
if (query === "") return items; const results: Map<
string,
const fuzzied: [T, number][] = []; {
item: T;
score: number;
}
> = new Map();
for (const item of items) { for (const item of items) {
const result = match(query, `${item[key]}`); if (options.limit && results.size >= options.limit) break;
if (!result.match) continue;
if (opts?.prefix || opts?.suffix) { for (const field in fields) {
fuzzied.push([ const result = match(query, `${item[field]}`);
{ if (!result.match) continue;
...item,
[key]: surround(`${item[key]}`, { const oldMatch = results.get(item.id);
result: result, const clonedItem = oldMatch?.item || clone(item);
prefix: opts?.prefix,
suffix: opts?.suffix if (options.suffix || options.prefix) {
}) clonedItem[field] = surround(`${clonedItem[field]}`, {
}, suffix: options.suffix,
result.score prefix: options.prefix,
]); result
} else fuzzied.push([item, result.score]); }) as T[Extract<keyof T, string>];
}
if (oldMatch) {
oldMatch.score += result.score * (fields[field] || 1);
} else {
results.set(item.id, {
item: clonedItem,
score: result.score * (fields[field] || 1)
});
}
}
} }
return fuzzied.sort((a, b) => b[1] - a[1]).map((f) => f[0]); if (results.size === 0) return [];
const sorted = Array.from(results.entries());
sorted.sort((a, b) => b[1].score - a[1].score);
return sorted.map((item) => item[1].item);
} }