feat: search use both languages and remove diacrites
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isChainable, TOOLS } from '$lib/registry';
|
import { isChainable, TOOLS } from '$lib/registry';
|
||||||
|
import { LOCALE_TAGS } from '$lib/i18n/dict';
|
||||||
|
import { getLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { normalizeForSearch, scoreDoc } from '$lib/i18n/matching';
|
||||||
import { t } from '$lib/i18n/t';
|
import { t } from '$lib/i18n/t';
|
||||||
import { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
|
import { toolDescription, toolSearchDoc, toolTitle } from '$lib/i18n/tool-strings';
|
||||||
import ToolCard from './ToolCard.svelte';
|
import ToolCard from './ToolCard.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,36 +27,11 @@
|
|||||||
score: number;
|
score: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function score(tool: (typeof TOOLS)[number], q: string): number | null {
|
|
||||||
if (q.length === 0) return 1;
|
|
||||||
const title = tool.title.toLowerCase();
|
|
||||||
const id = tool.id.toLowerCase();
|
|
||||||
const description = tool.description.toLowerCase();
|
|
||||||
if (title.startsWith(q)) return 100;
|
|
||||||
const inTitle = title.indexOf(q);
|
|
||||||
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
|
|
||||||
const inId = id.indexOf(q);
|
|
||||||
if (inId >= 0) return 60 - Math.min(inId, 40);
|
|
||||||
if (description.includes(q)) return 30;
|
|
||||||
let pos = 0;
|
|
||||||
let subScore = 20;
|
|
||||||
for (const ch of q) {
|
|
||||||
pos = title.indexOf(ch, pos);
|
|
||||||
if (pos < 0) {
|
|
||||||
subScore = -1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
subScore -= 1;
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
return subScore >= 0 ? subScore : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = $derived.by(() => {
|
const matches = $derived.by(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = normalizeForSearch(query.trim());
|
||||||
const found: Match[] = [];
|
const found: Match[] = [];
|
||||||
for (const tool of candidates) {
|
for (const tool of candidates) {
|
||||||
const s = score(tool, q);
|
const s = scoreDoc(toolSearchDoc(tool), q);
|
||||||
if (s !== null && s > 0) {
|
if (s !== null && s > 0) {
|
||||||
found.push({
|
found.push({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
@@ -64,11 +42,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
|
||||||
found.sort(
|
found.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b.score - a.score ||
|
b.score - a.score ||
|
||||||
b.popularity - a.popularity ||
|
b.popularity - a.popularity ||
|
||||||
a.title.localeCompare(b.title)
|
collator.compare(a.title, b.title)
|
||||||
);
|
);
|
||||||
return found.slice(0, 12);
|
return found.slice(0, 12);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TOOLS } from '../registry';
|
||||||
|
import { normalizeForSearch, scoreDoc, type SearchDoc } from './matching';
|
||||||
|
import { setLocale } from './locale.svelte';
|
||||||
|
import { toolSearchDoc } from './tool-strings';
|
||||||
|
|
||||||
|
describe('normalizeForSearch', () => {
|
||||||
|
it('нижний регистр', () => {
|
||||||
|
expect(normalizeForSearch('Rotate PNG')).toBe('rotate png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ё заменяется на е', () => {
|
||||||
|
expect(normalizeForSearch('Ёлка и ёж')).toBe('елка и еж');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('диакритика снимается через NFD', () => {
|
||||||
|
expect(normalizeForSearch('café')).toBe('cafe');
|
||||||
|
expect(normalizeForSearch('Über')).toBe('uber');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scoreDoc', () => {
|
||||||
|
const doc: SearchDoc = {
|
||||||
|
id: 'rotate-free-png',
|
||||||
|
titles: ['Повернуть на произвольный угол'],
|
||||||
|
descriptions: ['Поворот на любой угол. Холст расширяется под новые габариты.']
|
||||||
|
};
|
||||||
|
|
||||||
|
it('пустой запрос даёт нейтральный балл', () => {
|
||||||
|
expect(scoreDoc(doc, '')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('префикс названия ценнее подстроки', () => {
|
||||||
|
const prefix = scoreDoc(doc, normalizeForSearch('повер'));
|
||||||
|
const infix = scoreDoc(doc, normalizeForSearch('верну'));
|
||||||
|
expect(prefix!).toBe(100);
|
||||||
|
expect(infix!).toBeGreaterThan(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('подстрока названия ценнее совпадения в описании', () => {
|
||||||
|
const title = scoreDoc(doc, normalizeForSearch('угол'));
|
||||||
|
const desc = scoreDoc(doc, normalizeForSearch('габариты'));
|
||||||
|
expect(title!).toBeGreaterThan(desc!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('нет совпадения — null', () => {
|
||||||
|
expect(scoreDoc(doc, normalizeForSearch('квант'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('запрос с опечаткой ё/е всё равно находит', () => {
|
||||||
|
expect(scoreDoc(doc, normalizeForSearch('повёрнут'))).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('кросс-языковой поиск на реальном реестре', () => {
|
||||||
|
it('английский запрос находит инструмент при русской локали', () => {
|
||||||
|
setLocale('ru');
|
||||||
|
const hits = TOOLS.filter((tool) => {
|
||||||
|
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch('rotate'));
|
||||||
|
return s !== null && s > 0;
|
||||||
|
}).map((tool) => tool.id);
|
||||||
|
expect(hits).toContain('rotate-png');
|
||||||
|
expect(hits).toContain('rotate-free-png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('русский запрос находит инструмент при английской локали', () => {
|
||||||
|
setLocale('en');
|
||||||
|
try {
|
||||||
|
for (const q of ['повернуть', 'пово']) {
|
||||||
|
const hits = TOOLS.filter((tool) => {
|
||||||
|
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch(q));
|
||||||
|
return s !== null && s > 0;
|
||||||
|
}).map((tool) => tool.id);
|
||||||
|
expect(hits, `запрос «${q}» при en-локали`).toContain('rotate-png');
|
||||||
|
expect(hits, `запрос «${q}» при en-локали`).toContain('rotate-free-png');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLocale('ru');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('поиск работает и по описанию, не только по названию', () => {
|
||||||
|
setLocale('en');
|
||||||
|
try {
|
||||||
|
const hits = TOOLS.filter((tool) => {
|
||||||
|
const s = scoreDoc(toolSearchDoc(tool), normalizeForSearch('полупрозрачные'));
|
||||||
|
return s !== null && s > 0;
|
||||||
|
}).map((tool) => tool.id);
|
||||||
|
expect(hits).toContain('png-is-transparent');
|
||||||
|
} finally {
|
||||||
|
setLocale('ru');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Нормализация строки для поиска: нижний регистр, ё→е, снятие диакритики (NFD).
|
||||||
|
*/
|
||||||
|
export function normalizeForSearch(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('ё', 'е')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{M}/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchDoc = {
|
||||||
|
id: string;
|
||||||
|
titles: string[];
|
||||||
|
descriptions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBSEQUENCE_BASE = 20;
|
||||||
|
|
||||||
|
function bestTitleScore(titles: string[], q: string): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const raw of titles) {
|
||||||
|
const title = normalizeForSearch(raw);
|
||||||
|
if (title.startsWith(q)) return 100;
|
||||||
|
const at = title.indexOf(q);
|
||||||
|
if (at >= 0) {
|
||||||
|
const s = 80 - Math.min(at, 40);
|
||||||
|
if (best === null || s > best) best = s;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pos = 0;
|
||||||
|
let sub = SUBSEQUENCE_BASE;
|
||||||
|
for (const ch of q) {
|
||||||
|
pos = title.indexOf(ch, pos);
|
||||||
|
if (pos < 0) {
|
||||||
|
sub = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sub -= 1;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
if (sub >= 0 && (best === null || sub > best)) best = sub;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скоринг документа против нормализованного запроса.
|
||||||
|
* Пустой запрос — нейтральный балл; отсутствие совпадения — null.
|
||||||
|
*/
|
||||||
|
export function scoreDoc(doc: SearchDoc, normalizedQuery: string): number | null {
|
||||||
|
if (normalizedQuery.length === 0) return 1;
|
||||||
|
if (normalizeForSearch(doc.id).includes(normalizedQuery)) {
|
||||||
|
const at = normalizeForSearch(doc.id).indexOf(normalizedQuery);
|
||||||
|
const byId = 60 - Math.min(at, 40);
|
||||||
|
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
||||||
|
if (byTitle !== null && byTitle >= 80) return byTitle;
|
||||||
|
return Math.max(byId, byTitle ?? -1);
|
||||||
|
}
|
||||||
|
const byTitle = bestTitleScore(doc.titles, normalizedQuery);
|
||||||
|
if (byTitle !== null && byTitle >= 20) return byTitle;
|
||||||
|
for (const raw of doc.descriptions) {
|
||||||
|
if (normalizeForSearch(raw).includes(normalizedQuery)) return 30;
|
||||||
|
}
|
||||||
|
return byTitle !== null && byTitle > 0 ? byTitle : null;
|
||||||
|
}
|
||||||
@@ -31,3 +31,25 @@ export function optionLabel(tool: ToolEntry, param: ParamDef, value: string): st
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
return [...new Set(values.filter((v) => v.length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поисковый документ инструмента: строки активной локали, русского и английского
|
||||||
|
* (реестр) вместе — запрос находит инструмент по любому из языков независимо
|
||||||
|
* от того, какой сейчас включён.
|
||||||
|
*/
|
||||||
|
export function toolSearchDoc(tool: ToolEntry): SearchDoc {
|
||||||
|
const active = getMergedDict().tools[tool.id];
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
titles: dedupe([active?.title ?? '', ru.tools[tool.id]?.title ?? '', tool.title]),
|
||||||
|
descriptions: dedupe([
|
||||||
|
active?.description ?? '',
|
||||||
|
ru.tools[tool.id]?.description ?? '',
|
||||||
|
tool.description
|
||||||
|
])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user