feat(stats): add headword sentence search and rename related words

- Search by headword enabled by default; finds inflected variants (e.g. 知らない → 知らねえ)
- Add "Search by headword" toggle to switch back to exact text/title matching
- Rename "Similar Words" → "Related Seen Words" with tighter matching (same reading/shared kanji)
- ankiConnect.deck falls back to Yomitan mining deck when empty
This commit is contained in:
2026-06-06 01:50:01 -07:00
parent e18ccfe288
commit 83897c81d1
31 changed files with 1522 additions and 183 deletions
@@ -3,11 +3,30 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { formatSentenceSearchMatchCountLabel } from './SearchTab';
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => {
assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match');
assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches');
assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches');
});
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /secondaryText:\s*result\.secondaryText/);
});
test('SearchTab enables headword sentence search by default and forwards the toggle', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(
source,
/const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/,
);
assert.match(source, /apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/);
assert.match(source, /checked=\{searchByHeadword\}/);
assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/);
});
+34 -19
View File
@@ -41,8 +41,13 @@ function buttonLabel(
return 'Sentence';
}
export function formatSentenceSearchMatchCountLabel(count: number): string {
return count === 1 ? 'Match' : 'Matches';
}
export function SearchTab() {
const [query, setQuery] = useState('');
const [searchByHeadword, setSearchByHeadword] = useState(true);
const [results, setResults] = useState<SentenceSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -65,7 +70,7 @@ export function SearchTab() {
setError(null);
const timer = window.setTimeout(() => {
apiClient
.searchSentences(trimmed, SEARCH_LIMIT)
.searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword)
.then((nextResults) => {
if (requestId !== requestRef.current) return;
setResults(nextResults);
@@ -84,7 +89,7 @@ export function SearchTab() {
return () => {
window.clearTimeout(timer);
};
}, [query]);
}, [query, searchByHeadword]);
const handleMine = async (
result: SentenceSearchResult,
@@ -132,27 +137,37 @@ export function SearchTab() {
return (
<div className="space-y-4">
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<label className="min-w-0 flex-1">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
Sentence Search
</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search sentence text or media..."
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
autoComplete="off"
/>
</label>
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2 text-right">
<div className="text-xl font-semibold text-ctp-yellow">
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
Sentence Search
</span>
<div className="flex items-stretch gap-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search sentence text or media..."
className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
autoComplete="off"
aria-label="Sentence search"
/>
<div className="flex min-w-[5rem] flex-col items-center justify-center rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2">
<div className="text-xl font-semibold leading-none text-ctp-yellow">
{loading ? '...' : results.length}
</div>
<div className="text-[11px] uppercase tracking-wide text-ctp-overlay1">Matches</div>
<div className="mt-1 text-[11px] uppercase tracking-wide text-ctp-overlay1">
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
</div>
</div>
</div>
<label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-ctp-overlay1">
<input
type="checkbox"
checked={searchByHeadword}
onChange={(event) => setSearchByHeadword(event.target.checked)}
className="h-4 w-4 rounded border-ctp-surface2 bg-ctp-surface0 text-ctp-yellow focus:ring-ctp-yellow"
/>
Search by headword
</label>
</section>
{error && (
@@ -301,7 +301,7 @@ export function WordDetailPanel({
{data.similarWords.length > 0 && (
<section>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
Similar Words
Related Seen Words
</h3>
<div className="flex flex-wrap gap-1.5">
{data.similarWords.map((sw) => (
+7 -1
View File
@@ -103,7 +103,13 @@ test('searchSentences encodes realtime sentence search requests', async () => {
await apiClient.searchSentences('猫 食べる', 25);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25`,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`,
);
await apiClient.searchSentences('猫 食べる', 25, false);
assert.equal(
seenUrl,
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`,
);
} finally {
globalThis.fetch = originalFetch;
+2 -1
View File
@@ -116,11 +116,12 @@ export const apiClient = {
fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
),
searchSentences: (query: string, limit = 50) =>
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
fetchJson<SentenceSearchResult[]>(
`/api/stats/sentences/search?${new URLSearchParams({
q: query,
limit: String(limit),
headword: String(searchByHeadword),
}).toString()}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),