mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
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:
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
Reference in New Issue
Block a user