From e18ccfe288a54d6bc96e009e83ecf78e91a242a9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 6 Jun 2026 00:08:49 -0700 Subject: [PATCH] feat(stats): add Hide Kana filter and fix vocabulary exclusion matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- changes/fix-stats-vocab-exclusions.md | 4 + changes/overlay-mining-image-toast.md | 4 - changes/stats-hide-kana-filter.md | 4 + docs-site/anki-integration.md | 2 - src/anki-integration.test.ts | 41 ------- src/anki-integration.ts | 69 ++++------- src/main.ts | 6 - src/preload-settings.test.ts | 7 +- src/preload.ts | 8 -- src/renderer/index.html | 3 - src/renderer/renderer.ts | 24 ---- src/renderer/style.css | 31 ----- src/renderer/utils/dom.ts | 4 - src/shared/ipc/contracts.ts | 1 - src/types/runtime.ts | 6 - stats/src/App.tsx | 1 + stats/src/components/overview/OverviewTab.tsx | 8 +- .../components/overview/RecentSessions.tsx | 4 +- .../vocabulary/FrequencyRankTable.test.tsx | 45 ++++++- .../vocabulary/FrequencyRankTable.tsx | 115 +++++++++++++----- stats/src/hooks/useCoverImages.ts | 21 +++- stats/src/hooks/useExcludedWords.test.ts | 41 +++++++ stats/src/hooks/useExcludedWords.ts | 78 +++++++++--- stats/src/lib/cover-images.test.ts | 10 +- stats/src/lib/cover-images.ts | 8 ++ 25 files changed, 304 insertions(+), 241 deletions(-) create mode 100644 changes/fix-stats-vocab-exclusions.md delete mode 100644 changes/overlay-mining-image-toast.md create mode 100644 changes/stats-hide-kana-filter.md diff --git a/changes/fix-stats-vocab-exclusions.md b/changes/fix-stats-vocab-exclusions.md new file mode 100644 index 00000000..0595b321 --- /dev/null +++ b/changes/fix-stats-vocab-exclusions.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Fixed vocabulary exclusions so adding a word once hides matching token variants across the vocabulary page and duplicate exclusions are collapsed. diff --git a/changes/overlay-mining-image-toast.md b/changes/overlay-mining-image-toast.md deleted file mode 100644 index 0947fbc3..00000000 --- a/changes/overlay-mining-image-toast.md +++ /dev/null @@ -1,4 +0,0 @@ -type: added -area: overlay - -- Show a screenshot of the mined frame as an in-overlay image toast alongside the OSD card-mining notification, matching the picture already shown on system notifications. diff --git a/changes/stats-hide-kana-filter.md b/changes/stats-hide-kana-filter.md new file mode 100644 index 00000000..59a4b8ce --- /dev/null +++ b/changes/stats-hide-kana-filter.md @@ -0,0 +1,4 @@ +type: added +area: stats + +- Added a Hide Kana filter to the common-words frequency table so kana-only headwords can be hidden while reviewing mining targets. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index a0f89ab3..49f16ba3 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -220,8 +220,6 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) } ``` -`notificationType` controls how a mined-card confirmation is shown: `osd` draws an on-screen message over the video, `system` posts a desktop notification, `both` does both, and `none` stays silent. The `osd` (and `both`) path also flashes a small screenshot of the mined frame in the overlay, matching the picture shown on the system notification. - `overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged. ## AI Translation diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index f79c0858..03bf4b37 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -406,47 +406,6 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); -test('AnkiIntegration emits a mining image data URL to the overlay for OSD notifications', async () => { - const osdMessages: string[] = []; - const overlayImages: string[] = []; - const integration = new AnkiIntegration( - { - behavior: { - notificationType: 'osd', - }, - }, - {} as never, - { currentVideoPath: '/tmp/video.mkv', currentTimePos: 12 } as never, - (text) => { - osdMessages.push(text); - }, - ); - integration.setMiningImageOverlayCallback((image) => { - overlayImages.push(image); - }); - - const internals = integration as unknown as { - mediaGenerator: { - generateNotificationIcon: (path: string, timestamp: number) => Promise; - }; - showNotification: ( - noteId: number, - label: string | number, - errorSuffix?: string, - ) => Promise; - }; - internals.mediaGenerator = { - generateNotificationIcon: async () => Buffer.from('frame-bytes'), - } as never; - - await internals.showNotification(7, 'taberu'); - - assert.deepEqual(osdMessages, ['✓ Updated card: taberu']); - assert.deepEqual(overlayImages, [ - `data:image/png;base64,${Buffer.from('frame-bytes').toString('base64')}`, - ]); -}); - test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 22eff7fc..5a890c5c 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -148,7 +148,6 @@ export class AnkiIntegration { private runtime: AnkiIntegrationRuntime; private aiConfig: AiConfig; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; - private miningImageOverlayCallback: ((image: string) => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private noteIdRedirects = new Map(); @@ -1019,54 +1018,40 @@ export class AnkiIntegration { : `Updated card: ${label}`; const type = this.config.behavior?.notificationType || 'osd'; - const wantsOsd = type === 'osd' || type === 'both'; - const wantsSystem = - (type === 'system' || type === 'both') && this.notificationCallback !== null; - const wantsOverlayImage = wantsOsd && this.miningImageOverlayCallback !== null; - if (wantsOsd) { + if (type === 'osd' || type === 'both') { this.showUpdateResult(message, errorSuffix === undefined); } else { this.clearUpdateProgress(); } - if (!wantsSystem && !wantsOverlayImage) { - return; - } + if ((type === 'system' || type === 'both') && this.notificationCallback) { + let notificationIconPath: string | undefined; - // Generate the frame screenshot once and reuse it for the system notification - // icon and the in-overlay image toast. - let iconBuffer: Buffer | undefined; - if (this.mpvClient && this.mpvClient.currentVideoPath) { - try { - const timestamp = this.mpvClient.currentTimePos || 0; - const notificationIconSource = await resolveMediaGenerationInputPath( - this.mpvClient, - 'video', - ); - if (!notificationIconSource) { - throw new Error('No media source available for notification icon'); + if (this.mpvClient && this.mpvClient.currentVideoPath) { + try { + const timestamp = this.mpvClient.currentTimePos || 0; + const notificationIconSource = await resolveMediaGenerationInputPath( + this.mpvClient, + 'video', + ); + if (!notificationIconSource) { + throw new Error('No media source available for notification icon'); + } + const iconBuffer = await this.mediaGenerator.generateNotificationIcon( + notificationIconSource, + timestamp, + ); + if (iconBuffer && iconBuffer.length > 0) { + notificationIconPath = this.mediaGenerator.writeNotificationIconToFile( + iconBuffer, + noteId, + ); + } + } catch (err) { + log.warn('Failed to generate notification icon:', (err as Error).message); } - const buffer = await this.mediaGenerator.generateNotificationIcon( - notificationIconSource, - timestamp, - ); - if (buffer && buffer.length > 0) { - iconBuffer = buffer; - } - } catch (err) { - log.warn('Failed to generate notification icon:', (err as Error).message); } - } - - if (wantsOverlayImage && iconBuffer && this.miningImageOverlayCallback) { - this.miningImageOverlayCallback(`data:image/png;base64,${iconBuffer.toString('base64')}`); - } - - if (wantsSystem && this.notificationCallback) { - const notificationIconPath = iconBuffer - ? this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId) - : undefined; this.notificationCallback('Anki Card Updated', { body: message, @@ -1379,10 +1364,6 @@ export class AnkiIntegration { this.knownWordCacheUpdatedCallback = callback; } - setMiningImageOverlayCallback(callback: ((image: string) => void) | null): void { - this.miningImageOverlayCallback = callback; - } - setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void { this.consumeSubtitleMiningContextCallback = callback; } diff --git a/src/main.ts b/src/main.ts index 074c7b0b..2525d090 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3266,10 +3266,6 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void { overlayManager.broadcastToOverlayWindows(channel, ...args); } -function broadcastMiningImageToOverlay(image: string): void { - broadcastToOverlayWindows(IPC_CHANNELS.event.miningImage, { image }); -} - const buildBroadcastRuntimeOptionsChangedMainDepsHandler = createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({ broadcastRuntimeOptionsChangedRuntime, @@ -5815,7 +5811,6 @@ function initializeOverlayRuntime(): void { refreshCurrentSubtitleAfterKnownWordUpdate, ); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); - appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay); syncOverlayMpvSubtitleSuppression(); } @@ -6901,7 +6896,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.ankiIntegration?.setSubtitleMiningContextConsumer( consumePendingSubtitleMiningContext, ); - appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts index bdcaec15..06b730c4 100644 --- a/src/preload-settings.test.ts +++ b/src/preload-settings.test.ts @@ -34,13 +34,10 @@ test('overlay preload buffers only latest subtitle state until renderer listener assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/); }); -test('overlay preload buffers only latest mining image payload before listener registration', () => { +test('overlay preload does not expose the old mining image toast IPC path', () => { const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8'); - assert.match( - source, - /const onMiningImageEvent =\s*createLatestValueIpcListenerWithPayload\(\s*IPC_CHANNELS\.event\.miningImage,/, - ); + assert.doesNotMatch(source, /MiningImagePayload|onMiningImage|IPC_CHANNELS\.event\.miningImage/); }); test('overlay preload exposes queued pointer recovery requests', () => { diff --git a/src/preload.ts b/src/preload.ts index 2b17af56..7cf33083 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -51,7 +51,6 @@ import type { OverlayContentMeasurement, ShortcutsConfig, ConfigHotReloadPayload, - MiningImagePayload, ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, @@ -223,10 +222,6 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload payload as SecondarySubMode, ); -const onMiningImageEvent = createLatestValueIpcListenerWithPayload( - IPC_CHANNELS.event.miningImage, - (payload) => payload as MiningImagePayload, -); const electronAPI: ElectronAPI = { getOverlayLayer: () => overlayLayer, @@ -473,9 +468,6 @@ const electronAPI: ElectronAPI = { }, ); }, - onMiningImage: (callback: (payload: MiningImagePayload) => void) => { - onMiningImageEvent(callback); - }, }; contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/index.html b/src/renderer/index.html index f1de79f7..5b990df4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,9 +42,6 @@ role="status" aria-live="polite" > -
diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 1ac71b76..090f1c47 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -24,7 +24,6 @@ import type { SubtitlePosition, SubsyncManualPayload, ConfigHotReloadPayload, - MiningImagePayload, } from '../types'; import { createKeyboardHandlers } from './handlers/keyboard.js'; import { createGamepadController } from './handlers/gamepad-controller.js'; @@ -212,7 +211,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { let lastSubtitlePreview = ''; let lastSecondarySubtitlePreview = ''; let overlayErrorToastTimeout: ReturnType | null = null; -let miningImageToastTimeout: ReturnType | null = null; let controllerAnimationFrameId: number | null = null; function truncateForErrorLog(text: string): string { @@ -441,23 +439,6 @@ function showOverlayErrorToast(message: string): void { }, 3200); } -function showMiningImageToast(image: string): void { - if (!image) { - return; - } - if (miningImageToastTimeout) { - clearTimeout(miningImageToastTimeout); - miningImageToastTimeout = null; - } - ctx.dom.miningImageToastImage.src = image; - ctx.dom.miningImageToast.classList.remove('hidden'); - miningImageToastTimeout = setTimeout(() => { - ctx.dom.miningImageToast.classList.add('hidden'); - ctx.dom.miningImageToastImage.removeAttribute('src'); - miningImageToastTimeout = null; - }, 3000); -} - const recovery = createRendererRecoveryController({ dismissActiveUi: dismissActiveUiAfterError, restoreOverlayInteraction: restoreOverlayInteractionAfterError, @@ -752,11 +733,6 @@ async function init(): Promise { measurementReporter.schedule(); }); }); - window.electronAPI.onMiningImage((payload: MiningImagePayload) => { - runGuarded('mining:image', () => { - showMiningImageToast(payload.image); - }); - }); mouseHandlers.setupDragging(); try { ctx.state.controllerConfig = await window.electronAPI.getControllerConfig(); diff --git a/src/renderer/style.css b/src/renderer/style.css index 68e70ba6..3e7f402d 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -146,37 +146,6 @@ body:focus-visible, transform: translateY(0); } -.mining-image-toast { - position: absolute; - top: 80px; - right: 16px; - padding: 6px; - border-radius: 10px; - border: 1px solid rgba(138, 213, 202, 0.45); - background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94)); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); - pointer-events: none; - opacity: 0; - transform: translateY(-6px); - transition: - opacity 160ms ease, - transform 160ms ease; - z-index: 1300; -} - -.mining-image-toast-image { - display: block; - width: 128px; - height: 128px; - border-radius: 6px; - object-fit: cover; -} - -.mining-image-toast:not(.hidden) { - opacity: 1; - transform: translateY(0); -} - .modal { position: absolute; inset: 0; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 43c94325..bbddb2ac 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -4,8 +4,6 @@ export type RendererDom = { overlay: HTMLElement; controllerStatusToast: HTMLDivElement; overlayErrorToast: HTMLDivElement; - miningImageToast: HTMLDivElement; - miningImageToastImage: HTMLImageElement; secondarySubContainer: HTMLElement; secondarySubRoot: HTMLElement; @@ -136,8 +134,6 @@ export function resolveRendererDom(): RendererDom { overlay: getRequiredElement('overlay'), controllerStatusToast: getRequiredElement('controllerStatusToast'), overlayErrorToast: getRequiredElement('overlayErrorToast'), - miningImageToast: getRequiredElement('miningImageToast'), - miningImageToastImage: getRequiredElement('miningImageToastImage'), secondarySubContainer: getRequiredElement('secondarySubContainer'), secondarySubRoot: getRequiredElement('secondarySubRoot'), diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index fd2ceed4..449eb240 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -144,7 +144,6 @@ export const IPC_CHANNELS = { subtitleSidebarToggle: 'subtitle-sidebar:toggle', primarySubtitleBarToggle: 'primary-subtitle-bar:toggle', configHotReload: 'config:hot-reload', - miningImage: 'mining:image', }, } as const; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 9bcf2133..bd0e82a5 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -343,11 +343,6 @@ export interface ClipboardAppendResult { message: string; } -export interface MiningImagePayload { - /** Screenshot of the mined frame as a data URL (e.g. `data:image/png;base64,...`). */ - image: string; -} - export interface ConfigHotReloadPayload { keybindings: Keybinding[]; sessionBindings: CompiledSessionBinding[]; @@ -546,7 +541,6 @@ export interface ElectronAPI { ) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; - onMiningImage: (callback: (payload: MiningImagePayload) => void) => void; } declare global { diff --git a/stats/src/App.tsx b/stats/src/App.tsx index 88d39dfa..c977242b 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -188,6 +188,7 @@ export function App() { ) : null} diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index 89aea439..96bc88bb 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -20,9 +20,14 @@ import type { SessionSummary } from '../../types/stats'; interface OverviewTabProps { onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void; onNavigateToSession: (sessionId: number) => void; + isActive?: boolean; } -export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) { +export function OverviewTab({ + onNavigateToMediaDetail, + onNavigateToSession, + isActive = true, +}: OverviewTabProps) { const { data, sessions, setSessions, loading, error } = useOverview(); const { calendar, loading: calLoading } = useStreakCalendar(90); const [deleteError, setDeleteError] = useState(null); @@ -153,6 +158,7 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov onDeleteDayGroup={handleDeleteDayGroup} onDeleteAnimeGroup={handleDeleteAnimeGroup} deletingIds={deletingIds} + isActive={isActive} /> diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx index e9526113..f0908e83 100644 --- a/stats/src/components/overview/RecentSessions.tsx +++ b/stats/src/components/overview/RecentSessions.tsx @@ -20,6 +20,7 @@ interface RecentSessionsProps { onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void; onDeleteAnimeGroup: (sessions: SessionSummary[]) => void; deletingIds: Set; + isActive?: boolean; } interface AnimeGroup { @@ -352,8 +353,9 @@ export function RecentSessions({ onDeleteDayGroup, onDeleteAnimeGroup, deletingIds, + isActive = true, }: RecentSessionsProps) { - const coverImages = useCoverImages(sessions); + const coverImages = useCoverImages(sessions, { enabled: isActive }); if (sessions.length === 0) { return ( diff --git a/stats/src/components/vocabulary/FrequencyRankTable.test.tsx b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx index 37f64d2e..ea4dd274 100644 --- a/stats/src/components/vocabulary/FrequencyRankTable.test.tsx +++ b/stats/src/components/vocabulary/FrequencyRankTable.test.tsx @@ -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 { @@ -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( + , + ); + assert.match(markup, /Hide Kana/); +}); diff --git a/stats/src/components/vocabulary/FrequencyRankTable.tsx b/stats/src/components/vocabulary/FrequencyRankTable.tsx index 470a60c9..361344c1 100644 --- a/stats/src/components/vocabulary/FrequencyRankTable.tsx +++ b/stats/src/components/vocabulary/FrequencyRankTable.tsx @@ -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): 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, + 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(); + 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(); - 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 {hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'} -
+
{hasKnownData && ( )} + {ranked.length} words
{collapsed ? null : ranked.length === 0 ? (
- {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.'}
) : ( <> diff --git a/stats/src/hooks/useCoverImages.ts b/stats/src/hooks/useCoverImages.ts index a0051d10..bf695120 100644 --- a/stats/src/hooks/useCoverImages.ts +++ b/stats/src/hooks/useCoverImages.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { + buildCoverImageRequestKey, collectSessionCoverRequests, getCoverImageKey, mergeCoverImageData, @@ -9,15 +10,19 @@ import { getCoverRetryDelayMs } from '../lib/cover-retry'; import type { SessionSummary } from '../types/stats'; import { getStatsClient } from './useStatsApi'; -function buildRequestKey(animeIds: number[], videoIds: number[]): string { - return `a:${animeIds.join(',')}|m:${videoIds.join(',')}`; +interface UseCoverImagesOptions { + enabled?: boolean; } -export function useCoverImages(sessions: SessionSummary[]): CoverImageMap { +export function useCoverImages( + sessions: SessionSummary[], + options: UseCoverImagesOptions = {}, +): CoverImageMap { + const enabled = options.enabled ?? true; const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]); const requestKey = useMemo( - () => buildRequestKey(requests.animeIds, requests.videoIds), - [requests], + () => buildCoverImageRequestKey(requests.animeIds, requests.videoIds, enabled ? 1 : 0), + [requests, enabled], ); const [images, setImages] = useState({}); @@ -52,6 +57,12 @@ export function useCoverImages(sessions: SessionSummary[]): CoverImageMap { }, getCoverRetryDelayMs(attempt)); } + if (!enabled) { + return () => { + cancelled = true; + }; + } + if (requests.animeIds.length === 0 && requests.videoIds.length === 0) { setImages({}); return () => { diff --git a/stats/src/hooks/useExcludedWords.test.ts b/stats/src/hooks/useExcludedWords.test.ts index 9c96e34b..6f460e93 100644 --- a/stats/src/hooks/useExcludedWords.test.ts +++ b/stats/src/hooks/useExcludedWords.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + isExcludedWord, getExcludedWordsSnapshot, initializeExcludedWordsStore, resetExcludedWordsStoreForTests, @@ -100,6 +101,46 @@ test('setExcludedWords updates the database-backed exclusion list', async () => } }); +test('setExcludedWords persists one row per excluded token', async () => { + resetExcludedWordsStoreForTests(); + const { values: storage, restore } = installLocalStorage(); + const originalFetch = globalThis.fetch; + let seenBody = ''; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + seenBody = String(init?.body ?? ''); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof globalThis.fetch; + + try { + const rows = [ + { headword: 'ない', word: 'ない', reading: 'ない' }, + { headword: 'ない', word: '無い', reading: 'ない' }, + ]; + const expected = [{ headword: 'ない', word: 'ない', reading: 'ない' }]; + + await setExcludedWords(rows); + + assert.deepEqual(getExcludedWordsSnapshot(), expected); + assert.equal(seenBody, JSON.stringify({ words: expected })); + assert.equal(storage.get(STORAGE_KEY), JSON.stringify(expected)); + } finally { + globalThis.fetch = originalFetch; + restore(); + resetExcludedWordsStoreForTests(); + } +}); + +test('exclusion matching covers vocabulary rows with the same visible token', () => { + const excluded = [{ headword: 'ない', word: 'ない', reading: 'ない' }]; + + assert.equal(isExcludedWord(excluded, { headword: 'ない', word: '無い', reading: 'ない' }), true); + assert.equal(isExcludedWord(excluded, { headword: '無い', word: 'ない', reading: 'ない' }), true); + assert.equal( + isExcludedWord(excluded, { headword: 'なる', word: 'なる', reading: 'なる' }), + false, + ); +}); + test('setExcludedWords rolls back local state when persistence fails', async () => { resetExcludedWordsStoreForTests(); const previousRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }]; diff --git a/stats/src/hooks/useExcludedWords.ts b/stats/src/hooks/useExcludedWords.ts index 4af0ca3c..962f6592 100644 --- a/stats/src/hooks/useExcludedWords.ts +++ b/stats/src/hooks/useExcludedWords.ts @@ -6,8 +6,37 @@ export type ExcludedWord = StatsExcludedWord; const STORAGE_KEY = 'subminer-excluded-words'; -function toKey(w: ExcludedWord): string { - return `${w.headword}\0${w.word}\0${w.reading}`; +type ExclusionCandidate = { headword: string; word: string; reading: string }; + +function normalizedTokenText(value: string): string { + return value.trim(); +} + +export function getExcludedWordTokenKey(w: ExclusionCandidate): string { + return ( + normalizedTokenText(w.headword) || normalizedTokenText(w.word) || normalizedTokenText(w.reading) + ); +} + +function getExcludedWordAliasKeys(w: ExclusionCandidate): string[] { + const aliases = [normalizedTokenText(w.headword), normalizedTokenText(w.word)].filter(Boolean); + const unique = new Set(aliases); + if (unique.size === 0) unique.add(getExcludedWordTokenKey(w)); + return [...unique]; +} + +export function dedupeExcludedWords(words: ExcludedWord[]): ExcludedWord[] { + const byToken = new Map(); + for (const word of words) { + const key = getExcludedWordTokenKey(word); + if (!byToken.has(key)) byToken.set(key, word); + } + return [...byToken.values()]; +} + +export function isExcludedWord(excluded: ExcludedWord[], w: ExclusionCandidate): boolean { + const excludedKeys = new Set(excluded.flatMap(getExcludedWordAliasKeys)); + return getExcludedWordAliasKeys(w).some((key) => excludedKeys.has(key)); } let cached: ExcludedWord[] | null = null; @@ -22,13 +51,15 @@ function readLocalStorage(): ExcludedWord[] { const raw = localStorage.getItem(STORAGE_KEY); const parsed: unknown = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; - return parsed.filter( - (row): row is ExcludedWord => - row !== null && - typeof row === 'object' && - typeof (row as ExcludedWord).headword === 'string' && - typeof (row as ExcludedWord).word === 'string' && - typeof (row as ExcludedWord).reading === 'string', + return dedupeExcludedWords( + parsed.filter( + (row): row is ExcludedWord => + row !== null && + typeof row === 'object' && + typeof (row as ExcludedWord).headword === 'string' && + typeof (row as ExcludedWord).word === 'string' && + typeof (row as ExcludedWord).reading === 'string', + ), ); } catch { return []; @@ -48,14 +79,15 @@ function load(): ExcludedWord[] { function getKeySet(): Set { if (cachedKeys) return cachedKeys; - cachedKeys = new Set(load().map(toKey)); + cachedKeys = new Set(load().flatMap(getExcludedWordAliasKeys)); return cachedKeys; } function applyWords(words: ExcludedWord[]): void { - cached = words; - cachedKeys = new Set(words.map(toKey)); - writeLocalStorage(words); + const normalized = dedupeExcludedWords(words); + cached = normalized; + cachedKeys = new Set(normalized.flatMap(getExcludedWordAliasKeys)); + writeLocalStorage(normalized); for (const fn of listeners) fn(); } @@ -67,10 +99,11 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise { const previousWords = [...load()]; const previousRevision = revision; const writeRevision = previousRevision + 1; + const normalized = dedupeExcludedWords(words); revision = writeRevision; - applyWords(words); + applyWords(normalized); try { - await apiClient.setExcludedWords(words); + await apiClient.setExcludedWords(normalized); } catch (error) { if (revision === writeRevision) { revision = previousRevision; @@ -137,22 +170,27 @@ export function useExcludedWords() { }, []); const isExcluded = useCallback( - (w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)), + (w: ExclusionCandidate) => getExcludedWordAliasKeys(w).some((key) => getKeySet().has(key)), [excluded], ); const toggleExclusion = useCallback((w: ExcludedWord) => { - const key = toKey(w); const current = load(); - if (getKeySet().has(key)) { - void setExcludedWords(current.filter((e) => toKey(e) !== key)); + const candidateKeys = new Set(getExcludedWordAliasKeys(w)); + const existing = current.find((e) => + getExcludedWordAliasKeys(e).some((key) => candidateKeys.has(key)), + ); + if (existing) { + const key = getExcludedWordTokenKey(existing); + void setExcludedWords(current.filter((e) => getExcludedWordTokenKey(e) !== key)); } else { void setExcludedWords([...current, w]); } }, []); const removeExclusion = useCallback((w: ExcludedWord) => { - void setExcludedWords(load().filter((e) => toKey(e) !== toKey(w))); + const key = getExcludedWordTokenKey(w); + void setExcludedWords(load().filter((e) => getExcludedWordTokenKey(e) !== key)); }, []); const clearAll = useCallback(() => { diff --git a/stats/src/lib/cover-images.test.ts b/stats/src/lib/cover-images.test.ts index 348a234f..4e494193 100644 --- a/stats/src/lib/cover-images.test.ts +++ b/stats/src/lib/cover-images.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { collectSessionCoverRequests, getCoverImageKey } from './cover-images'; +import { + buildCoverImageRequestKey, + collectSessionCoverRequests, + getCoverImageKey, +} from './cover-images'; import type { SessionSummary } from '../types/stats'; function makeSession(overrides: Partial & { sessionId: number }): SessionSummary { @@ -42,3 +46,7 @@ test('getCoverImageKey separates anime and media ids', () => { assert.equal(getCoverImageKey('anime', 1), 'anime:1'); assert.equal(getCoverImageKey('media', 1), 'media:1'); }); + +test('buildCoverImageRequestKey changes when callers force a cover refresh', () => { + assert.notEqual(buildCoverImageRequestKey([10], [], 0), buildCoverImageRequestKey([10], [], 1)); +}); diff --git a/stats/src/lib/cover-images.ts b/stats/src/lib/cover-images.ts index aeaa21f8..707db792 100644 --- a/stats/src/lib/cover-images.ts +++ b/stats/src/lib/cover-images.ts @@ -22,6 +22,14 @@ export function getCoverImageKey(kind: CoverImageKind, id: number): string { return `${kind}:${id}`; } +export function buildCoverImageRequestKey( + animeIds: number[], + videoIds: number[], + refreshToken = 0, +): string { + return `a:${animeIds.join(',')}|m:${videoIds.join(',')}|r:${refreshToken}`; +} + export function collectSessionCoverRequests( sessions: Pick[], ): CoverImageRequest {