feat: search use both languages and remove diacrites
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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