feat: search use both languages and remove diacrites

This commit is contained in:
2026-08-25 09:07:57 +05:00
parent 6787201425
commit c04a1fc1dc
4 changed files with 190 additions and 29 deletions
@@ -1,7 +1,10 @@
<script lang="ts">
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 { toolDescription, toolTitle } from '$lib/i18n/tool-strings';
import { toolDescription, toolSearchDoc, toolTitle } from '$lib/i18n/tool-strings';
import ToolCard from './ToolCard.svelte';
interface Props {
@@ -24,36 +27,11 @@
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 q = query.trim().toLowerCase();
const q = normalizeForSearch(query.trim());
const found: Match[] = [];
for (const tool of candidates) {
const s = score(tool, q);
const s = scoreDoc(toolSearchDoc(tool), q);
if (s !== null && s > 0) {
found.push({
id: tool.id,
@@ -64,11 +42,12 @@
});
}
}
const collator = new Intl.Collator(LOCALE_TAGS[getLocale()]);
found.sort(
(a, b) =>
b.score - a.score ||
b.popularity - a.popularity ||
a.title.localeCompare(b.title)
collator.compare(a.title, b.title)
);
return found.slice(0, 12);
});
+94
View File
@@ -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');
}
});
});
+66
View File
@@ -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;
}
+22
View File
@@ -31,3 +31,25 @@ export function optionLabel(tool: ToolEntry, param: ParamDef, value: string): st
}
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
])
};
}