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 { transformQuery } from "../utils/query-transformer.js";
import { getSortSelectors } from "../utils/grouping.js";
import { fuzzy } from "../utils/fuzzy.js";
type SearchResults<T> = {
sorted: (limit?: number) => Promise<VirtualizedGrouping<T>>;
@@ -210,63 +211,17 @@ export default class Lookup {
suffix?: string;
} = {}
) {
const results: Map<
string,
{
item: T;
score: number;
}
> = new Map();
const columns = fields.map((f) => f.column);
const items = await selector.fields(columns).items();
for (const item of items) {
if (options.limit && results.size >= options.limit) break;
for (const field of fields) {
if (field.ignore) continue;
const result = match(query, `${item[field.name]}`);
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 fuzzy(
query,
items,
Object.fromEntries(
fields.filter((f) => !f.ignore).map((f) => [f.name, f.weight || 1])
) as Record<keyof T, number>,
options
);
return sorted.map((item) => ({
id: item[0],
score: item[1].score,
item: item[1].item
}));
}
private toSearchResults<T extends Item>(

View File

@@ -24,52 +24,69 @@ describe("lookup.fuzzy", () => {
test("should sort items by score", () => {
const items = [
{
id: "1",
title: "system"
},
{
id: "2",
title: "hello"
},
{
id: "3",
title: "items"
}
];
const query = "ems";
expect(fuzzy(query, items, "title")).toStrictEqual([items[2]]);
expect(fuzzy(query, items, { title: 1 })).toStrictEqual([items[2]]);
});
describe("opts.prefix", () => {
test("should prefix matched field with provided value when given", () => {
const items = [
{
id: "1",
title: "hello"
},
{
id: "2",
title: "world"
}
];
const query = "d";
expect(
fuzzy(query, items, "title", {
fuzzy(
query,
items,
{ title: 1 },
{
prefix: "prefix-"
})
).toStrictEqual([{ title: "worlprefix-d" }]);
}
)
).toStrictEqual([{ id: "2", title: "worlprefix-d" }]);
});
});
describe("opt.suffix", () => {
test("should suffix matched field with provided value when given", () => {
const items = [
{
id: "1",
title: "hello"
},
{
id: "2",
title: "world"
}
];
const query = "llo";
expect(
fuzzy(query, items, "title", {
fuzzy(
query,
items,
{ title: 1 },
{
suffix: "-suffix"
})
).toStrictEqual([{ title: "hello-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 { clone } from "./clone";
export function fuzzy<T>(
export function fuzzy<T extends { id: string }>(
query: string,
items: T[],
key: keyof T,
opts?: {
fields: Partial<Record<keyof T, number>>,
options: {
limit?: number;
prefix?: string;
suffix?: string;
}
} = {}
): T[] {
if (query === "") return items;
const fuzzied: [T, number][] = [];
const results: Map<
string,
{
item: T;
score: number;
}
> = new Map();
for (const item of items) {
const result = match(query, `${item[key]}`);
if (options.limit && results.size >= options.limit) break;
for (const field in fields) {
const result = match(query, `${item[field]}`);
if (!result.match) continue;
if (opts?.prefix || opts?.suffix) {
fuzzied.push([
{
...item,
[key]: surround(`${item[key]}`, {
result: result,
prefix: opts?.prefix,
suffix: opts?.suffix
})
},
result.score
]);
} else fuzzied.push([item, result.score]);
const oldMatch = results.get(item.id);
const clonedItem = oldMatch?.item || clone(item);
if (options.suffix || options.prefix) {
clonedItem[field] = surround(`${clonedItem[field]}`, {
suffix: options.suffix,
prefix: options.prefix,
result
}) 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);
}