mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
feat(stats): add Hide Kana filter and fix vocabulary exclusion matching
- Remove overlay mining image toast; OSD card notifications no longer flash a frame screenshot - Add Hide Kana toggle to frequency rank table to filter kana-only headwords - Fix vocab exclusions to deduplicate and match token variants (e.g. ない / 無い) under one exclusion entry - Skip cover image fetching when the overview tab is inactive
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
||||
import {
|
||||
buildFrequencyRankRows,
|
||||
FrequencyRankTable,
|
||||
isKanaOnlyTokenText,
|
||||
} from './FrequencyRankTable';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
||||
@@ -41,3 +45,42 @@ test('omits reading when reading equals headword', () => {
|
||||
'should not render any bracketed reading when equal to headword',
|
||||
);
|
||||
});
|
||||
|
||||
test('identifies kana-only token text without hiding mixed kanji words', () => {
|
||||
assert.equal(isKanaOnlyTokenText('さらに'), true);
|
||||
assert.equal(isKanaOnlyTokenText('バカ'), true);
|
||||
assert.equal(isKanaOnlyTokenText('カレー'), true);
|
||||
assert.equal(isKanaOnlyTokenText('前に'), false);
|
||||
assert.equal(isKanaOnlyTokenText('間違いない'), false);
|
||||
});
|
||||
|
||||
test('frequency rows can hide kana-only headwords', () => {
|
||||
const rows = buildFrequencyRankRows(
|
||||
[
|
||||
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
|
||||
makeEntry({
|
||||
wordId: 2,
|
||||
headword: '前に',
|
||||
word: '前に',
|
||||
reading: 'まえに',
|
||||
frequencyRank: 20,
|
||||
}),
|
||||
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ', frequencyRank: 30 }),
|
||||
],
|
||||
new Set(),
|
||||
{ hideKnown: false, hideKanaOnly: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.headword),
|
||||
['前に'],
|
||||
);
|
||||
});
|
||||
|
||||
test('renders a Hide Kana filter button', () => {
|
||||
const entry = makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' });
|
||||
const markup = renderToStaticMarkup(
|
||||
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||
);
|
||||
assert.match(markup, /Hide Kana/);
|
||||
});
|
||||
|
||||
@@ -11,45 +11,74 @@ interface FrequencyRankTableProps {
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface FrequencyRankOptions {
|
||||
hideKnown: boolean;
|
||||
hideKanaOnly: boolean;
|
||||
}
|
||||
|
||||
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u;
|
||||
|
||||
export function isKanaOnlyTokenText(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed);
|
||||
}
|
||||
|
||||
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
|
||||
return knownWords.has(w.headword) || knownWords.has(w.word);
|
||||
}
|
||||
|
||||
function isKanaOnlyWord(w: VocabularyEntry): boolean {
|
||||
return isKanaOnlyTokenText(w.headword || w.word);
|
||||
}
|
||||
|
||||
export function buildFrequencyRankRows(
|
||||
words: VocabularyEntry[],
|
||||
knownWords: Set<string>,
|
||||
options: FrequencyRankOptions,
|
||||
): VocabularyEntry[] {
|
||||
const hasKnownData = knownWords.size > 0;
|
||||
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
||||
if (options.hideKnown && hasKnownData) {
|
||||
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
|
||||
}
|
||||
if (options.hideKanaOnly) {
|
||||
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
|
||||
}
|
||||
|
||||
const byHeadword = new Map<string, VocabularyEntry>();
|
||||
for (const w of filtered) {
|
||||
const existing = byHeadword.get(w.headword);
|
||||
if (!existing) {
|
||||
byHeadword.set(w.headword, { ...w });
|
||||
} else {
|
||||
existing.frequency += w.frequency;
|
||||
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
|
||||
if (w.frequencyRank! < existing.frequencyRank!) {
|
||||
existing.frequencyRank = w.frequencyRank;
|
||||
}
|
||||
if (!existing.reading && w.reading) {
|
||||
existing.reading = w.reading;
|
||||
}
|
||||
if (!existing.partOfSpeech && w.partOfSpeech) {
|
||||
existing.partOfSpeech = w.partOfSpeech;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
||||
}
|
||||
|
||||
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
||||
const [page, setPage] = useState(0);
|
||||
const [hideKnown, setHideKnown] = useState(true);
|
||||
const [hideKanaOnly, setHideKanaOnly] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const hasKnownData = knownWords.size > 0;
|
||||
|
||||
const isWordKnown = (w: VocabularyEntry): boolean => {
|
||||
return knownWords.has(w.headword) || knownWords.has(w.word);
|
||||
};
|
||||
|
||||
const ranked = useMemo(() => {
|
||||
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
||||
if (hideKnown && hasKnownData) {
|
||||
filtered = filtered.filter((w) => !isWordKnown(w));
|
||||
}
|
||||
|
||||
const byHeadword = new Map<string, VocabularyEntry>();
|
||||
for (const w of filtered) {
|
||||
const existing = byHeadword.get(w.headword);
|
||||
if (!existing) {
|
||||
byHeadword.set(w.headword, { ...w });
|
||||
} else {
|
||||
existing.frequency += w.frequency;
|
||||
existing.animeCount = Math.max(existing.animeCount, w.animeCount);
|
||||
if (w.frequencyRank! < existing.frequencyRank!) {
|
||||
existing.frequencyRank = w.frequencyRank;
|
||||
}
|
||||
if (!existing.reading && w.reading) {
|
||||
existing.reading = w.reading;
|
||||
}
|
||||
if (!existing.partOfSpeech && w.partOfSpeech) {
|
||||
existing.partOfSpeech = w.partOfSpeech;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
||||
}, [words, knownWords, hideKnown, hasKnownData]);
|
||||
return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly });
|
||||
}, [words, knownWords, hideKnown, hideKanaOnly]);
|
||||
|
||||
if (words.every((w) => w.frequencyRank == null)) {
|
||||
return (
|
||||
@@ -81,10 +110,11 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
</span>
|
||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{hasKnownData && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={hideKnown}
|
||||
onClick={() => {
|
||||
setHideKnown(!hideKnown);
|
||||
setPage(0);
|
||||
@@ -98,12 +128,31 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
||||
Hide Known
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={hideKanaOnly}
|
||||
onClick={() => {
|
||||
setHideKanaOnly(!hideKanaOnly);
|
||||
setPage(0);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||
hideKanaOnly
|
||||
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
Hide Kana
|
||||
</button>
|
||||
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed ? null : ranked.length === 0 ? (
|
||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
||||
{hideKnown && hasKnownData && !hideKanaOnly
|
||||
? 'All ranked words are already in Anki!'
|
||||
: hideKnown || hideKanaOnly
|
||||
? 'No ranked words match the active filters.'
|
||||
: 'No words with frequency data.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user