diff --git a/changes/anki-maturity-known-word-highlighting.md b/changes/anki-maturity-known-word-highlighting.md new file mode 100644 index 00000000..ce8bfbe8 --- /dev/null +++ b/changes/anki-maturity-known-word-highlighting.md @@ -0,0 +1,4 @@ +type: added +area: overlay + +- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 115c0f0d..a6181217 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -407,6 +407,7 @@ See `config.example.jsonc` for detailed configuration options. | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | +| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index 6a6ee6fc..aefb4a6b 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -43,6 +43,30 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large. ::: +## Known-Word Maturity Highlighting + +Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned. + +**How it works:** + +1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`, `is:new`) - no extra card data is downloaded. +2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed). +3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting. +4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color. + +**Key settings:** + +| Option | Default | Description | +| -------------------------------------------- | --------- | ---------------------------------------------------------- | +| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) | +| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature | +| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards | +| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards | +| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards | +| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards | + +Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched. + ## Character-Name Highlighting Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail. @@ -131,6 +155,7 @@ All colors are customizable via the `subtitleStyle.jlptColors` object. These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting: - `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`) +- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`) - `ankiConnect.knownWords.matchMode` - `ankiConnect.nPlusOne.enabled` (`On` / `Off`) - `subtitleStyle.enableJlpt` (`On` / `Off`) @@ -146,6 +171,6 @@ When multiple annotations apply to the same token, the visual priority is: 1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations 2. **N+1 target** - the single unknown word in an N+1 sentence -3. **Known-word color** - already-learned token tint +3. **Known-word color** - already-learned token tint (per-tier maturity colors when `maturityEnabled` is on) 4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched) 5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match) diff --git a/src/anki-integration.ts b/src/anki-integration.ts index e0de77a0..29bcaeb0 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -28,6 +28,7 @@ import { NotificationOptions, } from './types/anki'; import { AiConfig } from './types/integrations'; +import type { KnownWordMaturityTier } from './types/subtitle'; import { MpvClient } from './types/runtime'; import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification'; import type { NotificationType, OverlayNotificationPayload } from './types/notification'; @@ -732,6 +733,14 @@ export class AnkiIntegration { return this.knownWordCache.isKnownWord(text, reading, options); } + getKnownWordTier( + text: string, + reading?: string, + options?: { allowReadingOnlyMatch?: boolean }, + ): KnownWordMaturityTier | null { + return this.knownWordCache.getKnownWordTier(text, reading, options); + } + getKnownWordMatchMode(): NPlusOneMatchMode { return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode; } diff --git a/src/anki-integration/known-word-cache-maturity.test.ts b/src/anki-integration/known-word-cache-maturity.test.ts new file mode 100644 index 00000000..f3af5b40 --- /dev/null +++ b/src/anki-integration/known-word-cache-maturity.test.ts @@ -0,0 +1,303 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { AnkiConnectConfig } from '../types/anki'; +import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache'; + +interface HarnessNoteInfo { + noteId: number; + fields: Record; +} + +function createMaturityHarness(config: AnkiConnectConfig): { + manager: KnownWordCacheManager; + calls: { findNotes: number; notesInfo: number; queries: string[] }; + statePath: string; + clientState: { + findNotesResult: number[]; + notesInfoResult: HarnessNoteInfo[]; + findNotesByQuery: Map; + }; + createSiblingManager: () => KnownWordCacheManager; + cleanup: () => void; +} { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-')); + const statePath = path.join(stateDir, 'known-words-cache.json'); + const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] }; + const clientState = { + findNotesResult: [] as number[], + notesInfoResult: [] as HarnessNoteInfo[], + findNotesByQuery: new Map(), + }; + const deps = { + client: { + findNotes: async (query: string) => { + calls.findNotes += 1; + calls.queries.push(query); + if (clientState.findNotesByQuery.has(query)) { + return clientState.findNotesByQuery.get(query) ?? []; + } + return clientState.findNotesResult; + }, + notesInfo: async (noteIds: number[]) => { + calls.notesInfo += 1; + return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId)); + }, + }, + getConfig: () => config, + knownWordCacheStatePath: statePath, + showStatusNotification: () => undefined, + }; + return { + manager: new KnownWordCacheManager(deps), + calls, + statePath, + clientState, + createSiblingManager: () => new KnownWordCacheManager(deps), + cleanup: () => { + fs.rmSync(stateDir, { recursive: true, force: true }); + }, + }; +} + +function maturityConfig(overrides: Partial = {}): AnkiConnectConfig { + return { + deck: 'Mining', + fields: { word: 'Word' }, + knownWords: { + highlightEnabled: true, + maturityEnabled: true, + refreshMinutes: 60, + }, + ...overrides, + }; +} + +test('lifecycle config key is unchanged when maturity is disabled', () => { + const disabled: AnkiConnectConfig = { + knownWords: { highlightEnabled: true, refreshMinutes: 60 }, + }; + // Upgrading users keep their persisted cache: the key must not gain fields + // while maturity is off. + assert.equal( + getKnownWordCacheLifecycleConfig(disabled), + '{"refreshMinutes":60,"scope":"all","fieldsWord":""}', + ); + + const enabled: AnkiConnectConfig = { + knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 }, + }; + assert.equal( + getKnownWordCacheLifecycleConfig(enabled), + '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', + ); + + const customThreshold: AnkiConnectConfig = { + knownWords: { + highlightEnabled: true, + maturityEnabled: true, + matureThresholdDays: 30, + refreshMinutes: 60, + }, + }; + assert.equal( + getKnownWordCacheLifecycleConfig(customThreshold), + '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}', + ); +}); + +test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => { + const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig()); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]); + clientState.notesInfoResult = [ + { noteId: 1, fields: { Word: { value: '猫' } } }, + { noteId: 2, fields: { Word: { value: '犬' } } }, + { noteId: 3, fields: { Word: { value: '鳥' } } }, + { noteId: 4, fields: { Word: { value: '魚' } } }, + ]; + + await manager.refresh(true); + + assert.equal(calls.findNotes, 4); + assert.equal(manager.getKnownWordTier('猫'), 'mature'); + assert.equal(manager.getKnownWordTier('犬'), 'young'); + assert.equal(manager.getKnownWordTier('鳥'), 'learning'); + assert.equal(manager.getKnownWordTier('魚'), 'new'); + assert.equal(manager.getKnownWordTier('馬'), null); + // Boolean matching still works alongside tiers. + assert.equal(manager.isKnownWord('猫'), true); + assert.equal(manager.isKnownWord('魚'), true); + } finally { + cleanup(); + } +}); + +test('a note with cards in several tiers counts as its most mature card', async () => { + const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); + clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; + + await manager.refresh(true); + + assert.equal(manager.getKnownWordTier('猫'), 'mature'); + } finally { + cleanup(); + } +}); + +test('a word matched by several notes takes the most mature note tier', async () => { + const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); + clientState.notesInfoResult = [ + { noteId: 1, fields: { Word: { value: '猫' } } }, + { noteId: 2, fields: { Word: { value: '猫' } } }, + ]; + + await manager.refresh(true); + + assert.equal(manager.getKnownWordTier('猫'), 'young'); + } finally { + cleanup(); + } +}); + +test('tiers are reading-aware for words with several readings', async () => { + const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); + clientState.notesInfoResult = [ + { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } }, + { noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } }, + ]; + + await manager.refresh(true); + + assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature'); + assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning'); + // No reading given: fail-open across readings, most mature wins. + assert.equal(manager.getKnownWordTier('床'), 'mature'); + // Unknown reading for a reading-locked word: no match, no tier. + assert.equal(manager.getKnownWordTier('床', 'しょう'), null); + } finally { + cleanup(); + } +}); + +test('reading-only fallback resolves tiers unless opted out', async () => { + const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig()); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', []); + clientState.notesInfoResult = [ + { noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } }, + ]; + + await manager.refresh(true); + + assert.equal(manager.getKnownWordTier('けいこく'), 'mature'); + assert.equal(manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }), null); + } finally { + cleanup(); + } +}); + +test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => { + const config = maturityConfig(); + config.knownWords = { ...config.knownWords, maturityEnabled: false }; + const { manager, calls, clientState, cleanup } = createMaturityHarness(config); + + try { + clientState.findNotesByQuery.set('deck:"Mining"', [1]); + clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; + + await manager.refresh(true); + + assert.equal(calls.findNotes, 1); + assert.equal(manager.isKnownWord('猫'), true); + assert.equal(manager.getKnownWordTier('猫'), null); + } finally { + cleanup(); + } +}); + +test('tiers persist to v4 state and reload without refetching', async () => { + const { manager, calls, statePath, clientState, createSiblingManager, cleanup } = + createMaturityHarness(maturityConfig()); + const originalDateNow = Date.now; + + try { + Date.now = () => 120_000; + clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); + clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); + clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); + clientState.notesInfoResult = [ + { noteId: 1, fields: { Word: { value: '猫' } } }, + { noteId: 2, fields: { Word: { value: '犬' } } }, + ]; + + await manager.refresh(true); + + const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as { + version: number; + tiers: Record; + }; + assert.equal(persisted.version, 4); + assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' }); + + const callsBeforeReload = calls.findNotes; + const reloaded = createSiblingManager(); + reloaded.startLifecycle(); + try { + assert.equal(reloaded.getKnownWordTier('猫'), 'mature'); + assert.equal(reloaded.getKnownWordTier('犬'), 'learning'); + assert.equal(calls.findNotes, callsBeforeReload); + } finally { + reloaded.stopLifecycle(); + } + } finally { + Date.now = originalDateNow; + cleanup(); + } +}); + +test('appendFromNoteInfo marks freshly mined notes as new tier', async () => { + const { manager, cleanup } = createMaturityHarness(maturityConfig()); + + try { + manager.appendFromNoteInfo({ + noteId: 7, + fields: { Word: { value: '猫' } }, + }); + + assert.equal(manager.isKnownWord('猫'), true); + assert.equal(manager.getKnownWordTier('猫'), 'new'); + } finally { + cleanup(); + } +}); diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts index 558d11af..3d54036b 100644 --- a/src/anki-integration/known-word-cache.test.ts +++ b/src/anki-integration/known-word-cache.test.ts @@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited version: number; notes?: Record>; }; - assert.equal(persisted.version, 3); + assert.equal(persisted.version, 4); assert.deepEqual(persisted.notes, { '1': [{ word: '鳥', reading: null }], }); diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 65b94953..15db5a21 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -4,7 +4,16 @@ import path from 'path'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getConfiguredWordFieldName } from '../anki-field-config'; import { AnkiConnectConfig } from '../types/anki'; +import type { KnownWordMaturityTier } from '../types/subtitle'; import { createLogger } from '../logger'; +import { + classifyKnownWordNoteTier, + fetchKnownWordMaturityTierSets, + getKnownWordMaturityEnabled, + getMatureIntervalThresholdDays, + maxKnownWordMaturityTier, + sanitizeKnownWordMaturityTier, +} from './known-word-maturity'; import { DEFAULT_KNOWN_WORD_READING_FIELDS, KnownWordEntry, @@ -62,11 +71,17 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri } export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string { - return JSON.stringify({ + const payload: Record = { refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config), scope: getKnownWordCacheScopeForConfig(config), fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '', - }); + }; + // The maturity field is only added while enabled so persisted caches from + // before the feature existed (or with it off) keep their identity. + if (getKnownWordMaturityEnabled(config)) { + payload.maturity = getMatureIntervalThresholdDays(config); + } + return JSON.stringify(payload); } export interface KnownWordCacheNoteInfo { @@ -96,7 +111,19 @@ interface KnownWordCacheStateV3 { readonly notes: Record; } -type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3; +interface KnownWordCacheStateV4 { + readonly version: 4; + readonly refreshedAtMs: number; + readonly scope: string; + readonly notes: Record; + readonly tiers: Record; +} + +type KnownWordCacheState = + | KnownWordCacheStateV1 + | KnownWordCacheStateV2 + | KnownWordCacheStateV3 + | KnownWordCacheStateV4; const NO_READING_KEY = ''; @@ -125,12 +152,13 @@ type KnownWordQueryScope = { export class KnownWordCacheManager { private knownWordsLastRefreshedAtMs = 0; private knownWordsStateKey = ''; - // word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY + // word → (hiragana reading | NO_READING_KEY → note ids). NO_READING_KEY // entries fail open: the word matches regardless of the token's reading. - private wordReadingCounts = new Map>(); - // hiragana reading → note count, so kana tokens still match by reading alone. - private readingCounts = new Map(); + private wordReadingNoteIds = new Map>>(); + // hiragana reading → note ids, so kana tokens still match by reading alone. + private readingNoteIds = new Map>(); private noteEntriesById = new Map(); + private noteTierById = new Map(); private knownWordsRefreshTimer: ReturnType | null = null; private knownWordsRefreshTimeout: ReturnType | null = null; private isRefreshingKnownWords = false; @@ -156,7 +184,7 @@ export class KnownWordCacheManager { return false; } - const knownReadings = this.wordReadingCounts.get(normalized); + const knownReadings = this.wordReadingNoteIds.get(normalized); if (knownReadings && knownReadings.size > 0) { const normalizedReading = typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : ''; @@ -168,7 +196,7 @@ export class KnownWordCacheManager { } // Callers that look up a kanji token's reading (not subtitle text) must - // opt out of the reading-only fallback: readingCounts holds readings of + // opt out of the reading-only fallback: readingNoteIds holds readings of // every note including kanji words, so 渓谷's けいこく would match a // mined 警告/けいこく. if (options?.allowReadingOnlyMatch === false) { @@ -182,7 +210,73 @@ export class KnownWordCacheManager { if ([...hiragana].length === 1) { return false; } - return this.readingCounts.has(hiragana); + return this.readingNoteIds.has(hiragana); + } + + // Maturity tier for a matching known word, following the exact matching + // rules of isKnownWord. A match with no tier data (tier fetch failed or + // pre-v4 cache) returns null so rendering falls back to the single + // known-word color. + getKnownWordTier( + text: string, + reading?: string, + options?: { allowReadingOnlyMatch?: boolean }, + ): KnownWordMaturityTier | null { + if (!getKnownWordMaturityEnabled(this.deps.getConfig())) { + return null; + } + + const normalized = this.normalizeKnownWordForLookup(text); + if (normalized.length === 0) { + return null; + } + + const knownReadings = this.wordReadingNoteIds.get(normalized); + if (knownReadings && knownReadings.size > 0) { + const normalizedReading = + typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : ''; + let tier: KnownWordMaturityTier | null = null; + if (normalizedReading.length === 0) { + for (const noteIds of knownReadings.values()) { + tier = this.maxTierForNotes(tier, noteIds); + } + return tier; + } + const noReadingNotes = knownReadings.get(NO_READING_KEY); + if (noReadingNotes) { + tier = this.maxTierForNotes(tier, noReadingNotes); + } + const exactReadingNotes = knownReadings.get(normalizedReading); + if (exactReadingNotes) { + tier = this.maxTierForNotes(tier, exactReadingNotes); + } + return tier; + } + + if (options?.allowReadingOnlyMatch === false) { + return null; + } + + const hiragana = convertKatakanaToHiragana(normalized); + if ([...hiragana].length === 1) { + return null; + } + const readingNotes = this.readingNoteIds.get(hiragana); + return readingNotes ? this.maxTierForNotes(null, readingNotes) : null; + } + + private maxTierForNotes( + current: KnownWordMaturityTier | null, + noteIds: ReadonlySet, + ): KnownWordMaturityTier | null { + let tier = current; + for (const noteId of noteIds) { + tier = maxKnownWordMaturityTier(tier, this.noteTierById.get(noteId) ?? null); + if (tier === 'mature') { + break; + } + } + return tier; } refresh(force = false): Promise { @@ -229,7 +323,7 @@ export class KnownWordCacheManager { let didMutateCache = false; const currentStateKey = this.getKnownWordCacheStateKey(); if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) { - didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0; + didMutateCache = this.wordReadingNoteIds.size > 0 || this.noteEntriesById.size > 0; this.clearKnownWordCacheState(); } if (!this.knownWordsStateKey) { @@ -247,6 +341,11 @@ export class KnownWordCacheManager { return didMutateCache; } + // A just-mined card has never been reviewed. + if (this.isMaturityTrackingEnabled() && this.noteEntriesById.has(noteInfo.noteId)) { + this.noteTierById.set(noteInfo.noteId, 'new'); + } + if (this.knownWordsLastRefreshedAtMs <= 0) { this.knownWordsLastRefreshedAtMs = Date.now(); } @@ -290,6 +389,13 @@ export class KnownWordCacheManager { this.isRefreshingKnownWords = true; try { const noteFieldsById = await this.fetchKnownWordNoteFieldsById(); + const tierSets = this.isMaturityTrackingEnabled() + ? await fetchKnownWordMaturityTierSets( + (query, options) => this.deps.client.findNotes(query, options), + this.getKnownWordQueryScopes().map((scope) => scope.query), + getMatureIntervalThresholdDays(this.deps.getConfig()), + ) + : null; const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b); if (this.noteEntriesById.size === 0) { @@ -316,13 +422,21 @@ export class KnownWordCacheManager { } } + this.noteTierById = new Map(); + if (tierSets) { + for (const noteId of currentNoteIds) { + this.noteTierById.set(noteId, classifyKnownWordNoteTier(noteId, tierSets)); + } + } + this.knownWordsLastRefreshedAtMs = Date.now(); this.knownWordsStateKey = frozenStateKey; this.persistKnownWordCacheState(); log.info( 'Known-word cache refreshed', `noteCount=${currentNoteIds.length}`, - `wordCount=${this.wordReadingCounts.size}`, + `wordCount=${this.wordReadingNoteIds.size}`, + tierSets ? `maturityTiers=${this.noteTierById.size}` : 'maturityTiers=off', ); } catch (error) { log.warn('Failed to refresh known-word cache:', (error as Error).message); @@ -337,6 +451,10 @@ export class KnownWordCacheManager { return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true; } + private isMaturityTrackingEnabled(): boolean { + return getKnownWordMaturityEnabled(this.deps.getConfig()); + } + private shouldAddMinedWordsImmediately(): boolean { return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false; } @@ -593,12 +711,13 @@ export class KnownWordCacheManager { return false; } - this.removeEntriesFromCounts(previousEntries); + this.removeEntriesFromIndexes(noteId, previousEntries); if (normalizedEntries.length > 0) { this.noteEntriesById.set(noteId, normalizedEntries); - this.addEntriesToCounts(normalizedEntries); + this.addEntriesToIndexes(noteId, normalizedEntries); } else { this.noteEntriesById.delete(noteId); + this.noteTierById.delete(noteId); } return true; } @@ -609,54 +728,68 @@ export class KnownWordCacheManager { return; } this.noteEntriesById.delete(noteId); - this.removeEntriesFromCounts(previousEntries); + this.noteTierById.delete(noteId); + this.removeEntriesFromIndexes(noteId, previousEntries); } - private addEntriesToCounts(entries: KnownWordEntry[]): void { + private addEntriesToIndexes(noteId: number, entries: KnownWordEntry[]): void { for (const entry of entries) { const readingKey = entry.reading ?? NO_READING_KEY; - let readings = this.wordReadingCounts.get(entry.word); + let readings = this.wordReadingNoteIds.get(entry.word); if (!readings) { readings = new Map(); - this.wordReadingCounts.set(entry.word, readings); + this.wordReadingNoteIds.set(entry.word, readings); } - readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1); + let noteIds = readings.get(readingKey); + if (!noteIds) { + noteIds = new Set(); + readings.set(readingKey, noteIds); + } + noteIds.add(noteId); if (entry.reading) { - this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1); + let readingNotes = this.readingNoteIds.get(entry.reading); + if (!readingNotes) { + readingNotes = new Set(); + this.readingNoteIds.set(entry.reading, readingNotes); + } + readingNotes.add(noteId); } } } - private removeEntriesFromCounts(entries: KnownWordEntry[]): void { + private removeEntriesFromIndexes(noteId: number, entries: KnownWordEntry[]): void { for (const entry of entries) { const readingKey = entry.reading ?? NO_READING_KEY; - const readings = this.wordReadingCounts.get(entry.word); + const readings = this.wordReadingNoteIds.get(entry.word); if (readings) { - const nextCount = (readings.get(readingKey) ?? 0) - 1; - if (nextCount > 0) { - readings.set(readingKey, nextCount); - } else { - readings.delete(readingKey); - if (readings.size === 0) { - this.wordReadingCounts.delete(entry.word); + const noteIds = readings.get(readingKey); + if (noteIds) { + noteIds.delete(noteId); + if (noteIds.size === 0) { + readings.delete(readingKey); + if (readings.size === 0) { + this.wordReadingNoteIds.delete(entry.word); + } } } } if (entry.reading) { - const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1; - if (nextReadingCount > 0) { - this.readingCounts.set(entry.reading, nextReadingCount); - } else { - this.readingCounts.delete(entry.reading); + const readingNotes = this.readingNoteIds.get(entry.reading); + if (readingNotes) { + readingNotes.delete(noteId); + if (readingNotes.size === 0) { + this.readingNoteIds.delete(entry.reading); + } } } } } private clearInMemoryState(): void { - this.wordReadingCounts = new Map(); - this.readingCounts = new Map(); + this.wordReadingNoteIds = new Map(); + this.readingNoteIds = new Map(); this.noteEntriesById = new Map(); + this.noteTierById = new Map(); this.knownWordsLastRefreshedAtMs = 0; } @@ -689,7 +822,7 @@ export class KnownWordCacheManager { } this.clearInMemoryState(); - if (parsed.version === 3) { + if (parsed.version === 3 || parsed.version === 4) { for (const [noteIdKey, entries] of Object.entries(parsed.notes)) { const noteId = Number.parseInt(noteIdKey, 10); if (!Number.isInteger(noteId) || noteId <= 0) { @@ -700,7 +833,16 @@ export class KnownWordCacheManager { continue; } this.noteEntriesById.set(noteId, normalizedEntries); - this.addEntriesToCounts(normalizedEntries); + this.addEntriesToIndexes(noteId, normalizedEntries); + } + if (parsed.version === 4) { + for (const [noteIdKey, tier] of Object.entries(parsed.tiers)) { + const noteId = Number.parseInt(noteIdKey, 10); + const sanitizedTier = sanitizeKnownWordMaturityTier(tier); + if (sanitizedTier && this.noteEntriesById.has(noteId)) { + this.noteTierById.set(noteId, sanitizedTier); + } + } } this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; this.knownWordsStateKey = parsed.scope; @@ -723,7 +865,7 @@ export class KnownWordCacheManager { continue; } this.noteEntriesById.set(noteId, normalizedEntries); - this.addEntriesToCounts(normalizedEntries); + this.addEntriesToIndexes(noteId, normalizedEntries); } this.knownWordsStateKey = parsed.scope; return; @@ -741,17 +883,23 @@ export class KnownWordCacheManager { private persistKnownWordCacheState(): void { try { const notes: Record = {}; + const tiers: Record = {}; for (const [noteId, entries] of this.noteEntriesById.entries()) { if (entries.length > 0) { notes[String(noteId)] = entries; + const tier = this.noteTierById.get(noteId); + if (tier) { + tiers[String(noteId)] = tier; + } } } - const state: KnownWordCacheStateV3 = { - version: 3, + const state: KnownWordCacheStateV4 = { + version: 4, refreshedAtMs: this.knownWordsLastRefreshedAtMs, scope: this.knownWordsStateKey, notes, + tiers, }; fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8'); } catch (error) { @@ -762,18 +910,33 @@ export class KnownWordCacheManager { private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState { if (typeof value !== 'object' || value === null) return false; const candidate = value as Record; - if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) { + if ( + candidate.version !== 1 && + candidate.version !== 2 && + candidate.version !== 3 && + candidate.version !== 4 + ) { return false; } if (typeof candidate.refreshedAtMs !== 'number') return false; if (typeof candidate.scope !== 'string') return false; - if (candidate.version !== 3) { + if (candidate.version === 1 || candidate.version === 2) { if (!Array.isArray(candidate.words)) return false; if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) { return false; } } - if (candidate.version === 2 || candidate.version === 3) { + if (candidate.version === 4) { + // Per-tier values are sanitized entry-by-entry at load time. + if ( + typeof candidate.tiers !== 'object' || + candidate.tiers === null || + Array.isArray(candidate.tiers) + ) { + return false; + } + } + if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) { if ( typeof candidate.notes !== 'object' || candidate.notes === null || diff --git a/src/anki-integration/known-word-maturity.test.ts b/src/anki-integration/known-word-maturity.test.ts new file mode 100644 index 00000000..f04a1bae --- /dev/null +++ b/src/anki-integration/known-word-maturity.test.ts @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { AnkiConnectConfig } from '../types/anki'; +import { + DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, + buildKnownWordMaturityTierQueries, + classifyKnownWordNoteTier, + getKnownWordMaturityEnabled, + getMatureIntervalThresholdDays, + maxKnownWordMaturityTier, + sanitizeKnownWordMaturityTier, +} from './known-word-maturity'; + +function makeConfig(knownWords: AnkiConnectConfig['knownWords']): AnkiConnectConfig { + return { url: 'http://127.0.0.1:8765', knownWords } as AnkiConnectConfig; +} + +test('maturity is enabled only when both highlight and maturity flags are on', () => { + assert.equal( + getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: true })), + true, + ); + assert.equal( + getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: false, maturityEnabled: true })), + false, + ); + assert.equal( + getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: false })), + false, + ); + assert.equal(getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true })), false); + assert.equal(getKnownWordMaturityEnabled(makeConfig(undefined)), false); +}); + +test('mature threshold falls back to default for invalid values', () => { + assert.equal(DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, 21); + assert.equal( + getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 30 })), + 30, + ); + assert.equal( + getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 14.9 })), + 14, + ); + assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 0 })), 21); + assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: -5 })), 21); + assert.equal( + getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: Number.NaN })), + 21, + ); + assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21); + assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21); +}); + +test('tier queries append Anki search props to a deck scope query', () => { + const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21); + assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21'); + assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21'); + assert.equal(queries.learning, 'deck:"Mining" is:learn'); +}); + +test('tier queries with an empty scope query have no leading space', () => { + const queries = buildKnownWordMaturityTierQueries('', 30); + assert.equal(queries.mature, 'prop:ivl>=30'); + assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30'); + assert.equal(queries.learning, 'is:learn'); +}); + +test('note classification picks the most mature matching tier', () => { + const sets = { + mature: new Set([1, 4]), + young: new Set([2, 4]), + learning: new Set([3, 4, 2]), + }; + assert.equal(classifyKnownWordNoteTier(1, sets), 'mature'); + assert.equal(classifyKnownWordNoteTier(2, sets), 'young'); + assert.equal(classifyKnownWordNoteTier(3, sets), 'learning'); + // Note with mature, young, and learning cards: most mature card wins. + assert.equal(classifyKnownWordNoteTier(4, sets), 'mature'); + assert.equal(classifyKnownWordNoteTier(99, sets), 'new'); +}); + +test('maxKnownWordMaturityTier picks the higher tier and tolerates null', () => { + assert.equal(maxKnownWordMaturityTier('mature', 'new'), 'mature'); + assert.equal(maxKnownWordMaturityTier('learning', 'young'), 'young'); + assert.equal(maxKnownWordMaturityTier('new', null), 'new'); + assert.equal(maxKnownWordMaturityTier(null, 'learning'), 'learning'); + assert.equal(maxKnownWordMaturityTier(null, null), null); + assert.equal(maxKnownWordMaturityTier(undefined, undefined), null); +}); + +test('sanitizeKnownWordMaturityTier accepts only valid tiers', () => { + assert.equal(sanitizeKnownWordMaturityTier('mature'), 'mature'); + assert.equal(sanitizeKnownWordMaturityTier('young'), 'young'); + assert.equal(sanitizeKnownWordMaturityTier('learning'), 'learning'); + assert.equal(sanitizeKnownWordMaturityTier('new'), 'new'); + assert.equal(sanitizeKnownWordMaturityTier('MATURE'), null); + assert.equal(sanitizeKnownWordMaturityTier(''), null); + assert.equal(sanitizeKnownWordMaturityTier(21), null); + assert.equal(sanitizeKnownWordMaturityTier(null), null); + assert.equal(sanitizeKnownWordMaturityTier(undefined), null); +}); diff --git a/src/anki-integration/known-word-maturity.ts b/src/anki-integration/known-word-maturity.ts new file mode 100644 index 00000000..13cf3366 --- /dev/null +++ b/src/anki-integration/known-word-maturity.ts @@ -0,0 +1,102 @@ +import type { AnkiConnectConfig } from '../types/anki'; +import type { KnownWordMaturityTier } from '../types/subtitle'; + +export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21; + +// Ascending maturity; index order backs tier comparison. +const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature']; + +export interface KnownWordMaturityTierQueries { + mature: string; + young: string; + learning: string; +} + +export interface KnownWordMaturityTierSets { + mature: ReadonlySet; + young: ReadonlySet; + learning: ReadonlySet; +} + +// Maturity tiers only affect how known-word highlights render, so both flags +// must be on before tier data is fetched or served. +export function getKnownWordMaturityEnabled(config: AnkiConnectConfig): boolean { + return ( + config.knownWords?.highlightEnabled === true && config.knownWords?.maturityEnabled === true + ); +} + +export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): number { + const threshold = config.knownWords?.matureThresholdDays; + if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1) { + return Math.floor(threshold); + } + return DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS; +} + +// Anki search props classify notes server-side: a note matches a tier query +// when ANY of its cards matches, which implements most-mature-card-wins for +// free once tiers are checked in mature > young > learning order. +export function buildKnownWordMaturityTierQueries( + scopeQuery: string, + thresholdDays: number, +): KnownWordMaturityTierQueries { + const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : ''; + return { + mature: `${prefix}prop:ivl>=${thresholdDays}`, + young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`, + learning: `${prefix}is:learn`, + }; +} + +export async function fetchKnownWordMaturityTierSets( + findNotes: (query: string, options?: { maxRetries?: number }) => Promise, + scopeQueries: string[], + thresholdDays: number, +): Promise<{ mature: Set; young: Set; learning: Set }> { + const sets = { + mature: new Set(), + young: new Set(), + learning: new Set(), + }; + for (const scopeQuery of scopeQueries) { + const queries = buildKnownWordMaturityTierQueries(scopeQuery, thresholdDays); + for (const tier of ['mature', 'young', 'learning'] as const) { + const noteIds = (await findNotes(queries[tier], { maxRetries: 0 })) as number[]; + if (!Array.isArray(noteIds)) { + continue; + } + for (const noteId of noteIds) { + if (Number.isInteger(noteId) && noteId > 0) { + sets[tier].add(noteId); + } + } + } + } + return sets; +} + +export function classifyKnownWordNoteTier( + noteId: number, + sets: KnownWordMaturityTierSets, +): KnownWordMaturityTier { + if (sets.mature.has(noteId)) return 'mature'; + if (sets.young.has(noteId)) return 'young'; + if (sets.learning.has(noteId)) return 'learning'; + return 'new'; +} + +export function maxKnownWordMaturityTier( + a: KnownWordMaturityTier | null | undefined, + b: KnownWordMaturityTier | null | undefined, +): KnownWordMaturityTier | null { + if (!a) return b ?? null; + if (!b) return a; + return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b; +} + +export function sanitizeKnownWordMaturityTier(value: unknown): KnownWordMaturityTier | null { + return typeof value === 'string' && TIER_ORDER.includes(value as KnownWordMaturityTier) + ? (value as KnownWordMaturityTier) + : null; +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 864dcc8b..60c12f11 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2182,6 +2182,7 @@ test('runtime options registry is centralized', () => { assert.deepEqual(ids, [ 'anki.autoUpdateNewCards', 'subtitle.annotation.knownWords.highlightEnabled', + 'subtitle.annotation.knownWords.maturityEnabled', 'subtitle.annotation.nPlusOne', 'subtitle.annotation.jlpt', 'subtitle.annotation.frequency', diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index bb18a955..88941c38 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -60,6 +60,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, knownWords: { highlightEnabled: false, + maturityEnabled: false, + matureThresholdDays: 21, refreshMinutes: 1440, addMinedWordsImmediately: true, matchMode: 'headword', diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 9985f070..cadce14a 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -32,6 +32,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick (value === true ? 'On' : 'Off'), + toAnkiPatch: (value) => ({ + knownWords: { + maturityEnabled: value === true, + }, + }), + }, { id: 'subtitle.annotation.nPlusOne', path: 'ankiConnect.nPlusOne.enabled', diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 980b9145..b82e15f8 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -353,6 +353,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s path.startsWith('ankiConnect.nPlusOne.') || path.startsWith('subtitleStyle.frequencyDictionary.') || path.startsWith('subtitleStyle.jlptColors.') || + path.startsWith('subtitleStyle.knownWordMaturityColors.') || path === 'subtitleStyle.enableJlpt' || path === 'subtitleStyle.knownWordColor' || path === 'subtitleStyle.nPlusOneColor' || diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 34430d79..676dd213 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -10,6 +10,7 @@ import { Token, FrequencyDictionaryLookup, JlptLevel, + KnownWordMaturityTier, PartOfSpeech, } from '../../types'; import { @@ -43,6 +44,12 @@ export type KnownWordLookupFn = ( options?: { allowReadingOnlyMatch?: boolean }, ) => boolean; +export type KnownWordTierLookupFn = ( + text: string, + reading?: string, + options?: { allowReadingOnlyMatch?: boolean }, +) => KnownWordMaturityTier | null; + export interface TokenizerServiceDeps { getYomitanExt: () => Extension | null; getYomitanSession?: () => Session | null; @@ -53,6 +60,7 @@ export interface TokenizerServiceDeps { getYomitanParserInitPromise: () => Promise | null; setYomitanParserInitPromise: (promise: Promise | null) => void; isKnownWord: KnownWordLookupFn; + getKnownWordTier?: KnownWordTierLookupFn; getKnownWordMatchMode: () => NPlusOneMatchMode; getKnownWordsEnabled?: () => boolean; getJlptLevel: (text: string) => JlptLevel | null; @@ -88,6 +96,7 @@ export interface TokenizerDepsRuntimeOptions { getYomitanParserInitPromise: () => Promise | null; setYomitanParserInitPromise: (promise: Promise | null) => void; isKnownWord: KnownWordLookupFn; + getKnownWordTier?: KnownWordTierLookupFn; getKnownWordMatchMode: () => NPlusOneMatchMode; getKnownWordsEnabled?: () => boolean; getJlptLevel: (text: string) => JlptLevel | null; @@ -204,6 +213,11 @@ async function applyAnnotationStage( tokens, { isKnownWord: getKnownWordLookup(deps, options), + // Maturity tiers only refine known-word rendering, so they follow the + // known-word toggle rather than the N+1 gate. + ...(options.knownWordsEnabled && deps.getKnownWordTier + ? { getKnownWordTier: deps.getKnownWordTier } + : {}), knownWordMatchMode: deps.getKnownWordMatchMode(), getJlptLevel: deps.getJlptLevel, }, @@ -242,6 +256,7 @@ export function createTokenizerDepsRuntime( getYomitanParserInitPromise: options.getYomitanParserInitPromise, setYomitanParserInitPromise: options.setYomitanParserInitPromise, isKnownWord: options.isKnownWord, + getKnownWordTier: options.getKnownWordTier, getKnownWordMatchMode: options.getKnownWordMatchMode, getKnownWordsEnabled: options.getKnownWordsEnabled, getJlptLevel: options.getJlptLevel, diff --git a/src/core/services/tokenizer/annotation-stage-maturity.test.ts b/src/core/services/tokenizer/annotation-stage-maturity.test.ts new file mode 100644 index 00000000..49541295 --- /dev/null +++ b/src/core/services/tokenizer/annotation-stage-maturity.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { KnownWordMaturityTier, MergedToken, PartOfSpeech } from '../../../types'; +import { annotateTokens, AnnotationStageDeps } from './annotation-stage'; + +function makeToken(overrides: Partial = {}): MergedToken { + return { + surface: '猫', + reading: 'ネコ', + headword: '猫', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}): AnnotationStageDeps { + return { + isKnownWord: () => false, + knownWordMatchMode: 'headword', + getJlptLevel: () => null, + ...overrides, + }; +} + +test('annotateTokens attaches maturity tier to known tokens', () => { + const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })]; + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === '食べる', + getKnownWordTier: (text) => (text === '食べる' ? 'mature' : null), + }), + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.knownMaturity, 'mature'); +}); + +test('annotateTokens leaves maturity undefined without a tier lookup dep', () => { + const tokens = [makeToken()]; + const result = annotateTokens(tokens, makeDeps({ isKnownWord: () => true })); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.knownMaturity, undefined); +}); + +test('annotateTokens leaves maturity undefined when the tier lookup has no data', () => { + const tokens = [makeToken()]; + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: () => true, getKnownWordTier: () => null }), + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.knownMaturity, undefined); +}); + +test('annotateTokens never attaches maturity to unknown tokens', () => { + const tokens = [makeToken()]; + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: () => false, getKnownWordTier: () => 'mature' }), + ); + + assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.knownMaturity, undefined); +}); + +test('annotateTokens resolves maturity through the kana reading fallback', () => { + // Token 大体 with a card mined in kana (だいたい): known status comes from + // the reading fallback, so the tier must follow the same path. + const tierByText = new Map([['だいたい', 'young']]); + const seenTierLookups: Array<{ + text: string; + reading: string | undefined; + allowReadingOnlyMatch: boolean | undefined; + }> = []; + const tokens = [ + makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === 'だいたい', + getKnownWordTier: (text, reading, options) => { + seenTierLookups.push({ + text, + reading, + allowReadingOnlyMatch: options?.allowReadingOnlyMatch, + }); + return tierByText.get(text) ?? null; + }, + }), + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.knownMaturity, 'young'); + // The fallback lookup must opt out of reading-only matching, exactly like + // the boolean known-status fallback. + const fallbackLookup = seenTierLookups.find((lookup) => lookup.text === 'だいたい'); + assert.equal(fallbackLookup?.allowReadingOnlyMatch, false); +}); + +test('annotateTokens keeps maturity on POS-excluded known tokens', () => { + // Known-word annotations survive the POS noise filter; the tier must too. + const tokens = [makeToken({ surface: 'の', headword: 'の', reading: 'ノ', pos1: '助詞' })]; + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'learning' }), + ); + + assert.equal(result[0]?.isKnown, true); + assert.equal(result[0]?.knownMaturity, 'learning'); +}); + +test('annotateTokens strips maturity when known-word annotation is disabled', () => { + const tokens = [makeToken({ knownMaturity: 'mature' })]; + const result = annotateTokens( + tokens, + makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'mature' }), + { knownWordsEnabled: false }, + ); + + assert.equal(result[0]?.isKnown, false); + assert.equal(result[0]?.knownMaturity, undefined); +}); diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 5a208531..558e065d 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -7,7 +7,13 @@ import { DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG, resolveAnnotationPos2ExclusionSet, } from '../../../token-pos2-exclusions'; -import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types'; +import { + JlptLevel, + KnownWordMaturityTier, + MergedToken, + NPlusOneMatchMode, + PartOfSpeech, +} from '../../../types'; import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter'; import { shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations, @@ -36,6 +42,11 @@ export interface AnnotationStageDeps { reading?: string, options?: { allowReadingOnlyMatch?: boolean }, ) => boolean; + getKnownWordTier?: ( + text: string, + reading?: string, + options?: { allowReadingOnlyMatch?: boolean }, + ) => KnownWordMaturityTier | null; knownWordMatchMode: NPlusOneMatchMode; getJlptLevel: (text: string) => JlptLevel | null; } @@ -616,6 +627,28 @@ function computeTokenKnownStatus( ); } +// Maturity tier for a token already confirmed known, following the same +// primary + kana-fallback lookup sequence as computeTokenKnownStatus so the +// tier always describes a note the boolean match could have come from. +function computeTokenKnownMaturity( + token: MergedToken, + getKnownWordTier: NonNullable, + knownWordMatchMode: NPlusOneMatchMode, +): KnownWordMaturityTier | undefined { + const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode); + const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode); + const primaryTier = matchText ? getKnownWordTier(matchText, matchReading) : null; + if (primaryTier) { + return primaryTier; + } + + const fallbackReading = resolveCompleteTokenReading(token); + if (!fallbackReading || fallbackReading === matchText.trim()) { + return undefined; + } + return getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined; +} + function filterTokenFrequencyRank( token: MergedToken, pos1Exclusions: ReadonlySet, @@ -677,6 +710,11 @@ export function annotateTokens( : false; nPlusOneKnownStatuses[index] = isKnownForMatching; + const knownMaturity = + knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier + ? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode) + : undefined; + const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true; // A confirmed character-name match must survive the POS noise filter: @@ -696,6 +734,7 @@ export function annotateTokens( return { ...strippedToken, isKnown: knownWordsEnabled ? isKnownForMatching : false, + knownMaturity, }; } @@ -712,6 +751,7 @@ export function annotateTokens( return { ...token, isKnown: knownWordsEnabled ? isKnownForMatching : false, + knownMaturity, isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false, frequencyRank, jlptLevel, diff --git a/src/main.ts b/src/main.ts index c75fef15..880a5193 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4560,6 +4560,8 @@ const { }, isKnownWord: (text, reading, options) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)), + getKnownWordTier: (text, reading, options) => + appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null, recordLookup: (hit) => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordLookup(hit); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index d7b6449a..23af065b 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -42,6 +42,12 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { deps.recordLookup(hit); return hit; }, + ...(deps.getKnownWordTier + ? { + getKnownWordTier: (text, reading, options) => + deps.getKnownWordTier!(text, reading, options), + } + : {}), getKnownWordMatchMode: () => deps.getKnownWordMatchMode(), ...(deps.getKnownWordsEnabled ? { diff --git a/src/renderer/state.ts b/src/renderer/state.ts index e87cbcf3..686c727e 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -116,6 +116,10 @@ export type RendererState = { subtitleSidebarPausedByHover: boolean; knownWordColor: string; + knownWordMaturityNewColor: string; + knownWordMaturityLearningColor: string; + knownWordMaturityYoungColor: string; + knownWordMaturityMatureColor: string; nPlusOneColor: string; nameMatchEnabled: boolean; nameMatchColor: string; @@ -240,6 +244,10 @@ export function createRendererState(): RendererState { subtitleSidebarPausedByHover: false, knownWordColor: '#a6da95', + knownWordMaturityNewColor: '#ee99a0', + knownWordMaturityLearningColor: '#b7bdf8', + knownWordMaturityYoungColor: '#91d7e3', + knownWordMaturityMatureColor: '#a6da95', nPlusOneColor: '#c6a0f6', nameMatchEnabled: false, nameMatchColor: '#f5bde6', diff --git a/src/renderer/style.css b/src/renderer/style.css index 703ef775..6b64709e 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1503,6 +1503,24 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] { color: var(--subtitle-known-word-color, #a6da95); } +/* Anki maturity tiers ride on word-known and only override its color, so + hover/selection rules keyed on word-known keep applying. */ +#subtitleRoot .word.word-known.word-maturity-new { + color: var(--subtitle-maturity-new-color, #ee99a0); +} + +#subtitleRoot .word.word-known.word-maturity-learning { + color: var(--subtitle-maturity-learning-color, #b7bdf8); +} + +#subtitleRoot .word.word-known.word-maturity-young { + color: var(--subtitle-maturity-young-color, #91d7e3); +} + +#subtitleRoot .word.word-known.word-maturity-mature { + color: var(--subtitle-maturity-mature-color, #a6da95); +} + #subtitleRoot .word.word-n-plus-one { color: var(--subtitle-n-plus-one-color, #c6a0f6); } @@ -1680,6 +1698,30 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] { -webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important; } +#subtitleRoot .word.word-known.word-maturity-new::selection, +#subtitleRoot .word.word-known.word-maturity-new .c::selection { + color: var(--subtitle-maturity-new-color, #ee99a0) !important; + -webkit-text-fill-color: var(--subtitle-maturity-new-color, #ee99a0) !important; +} + +#subtitleRoot .word.word-known.word-maturity-learning::selection, +#subtitleRoot .word.word-known.word-maturity-learning .c::selection { + color: var(--subtitle-maturity-learning-color, #b7bdf8) !important; + -webkit-text-fill-color: var(--subtitle-maturity-learning-color, #b7bdf8) !important; +} + +#subtitleRoot .word.word-known.word-maturity-young::selection, +#subtitleRoot .word.word-known.word-maturity-young .c::selection { + color: var(--subtitle-maturity-young-color, #91d7e3) !important; + -webkit-text-fill-color: var(--subtitle-maturity-young-color, #91d7e3) !important; +} + +#subtitleRoot .word.word-known.word-maturity-mature::selection, +#subtitleRoot .word.word-known.word-maturity-mature .c::selection { + color: var(--subtitle-maturity-mature-color, #a6da95) !important; + -webkit-text-fill-color: var(--subtitle-maturity-mature-color, #a6da95) !important; +} + #subtitleRoot .word.word-n-plus-one::selection, #subtitleRoot .word.word-n-plus-one .c::selection { color: var(--subtitle-n-plus-one-color, #c6a0f6) !important; diff --git a/src/renderer/subtitle-render-word-class.test.ts b/src/renderer/subtitle-render-word-class.test.ts index ece64e53..e151620a 100644 --- a/src/renderer/subtitle-render-word-class.test.ts +++ b/src/renderer/subtitle-render-word-class.test.ts @@ -204,3 +204,54 @@ test('computeWordClass skips frequency class when rank is out of topX', () => { assert.equal(actual, 'word'); }); + +test('computeWordClass adds the maturity tier class alongside word-known', () => { + const token = createToken({ + isKnown: true, + knownMaturity: 'mature', + surface: '猫', + }); + + assert.equal(computeWordClass(token), 'word word-known word-maturity-mature'); +}); + +test('computeWordClass keeps the plain known class when no maturity tier is set', () => { + const token = createToken({ + isKnown: true, + surface: '猫', + }); + + assert.equal(computeWordClass(token), 'word word-known'); +}); + +test('computeWordClass composes maturity with JLPT classes', () => { + const token = createToken({ + isKnown: true, + knownMaturity: 'young', + jlptLevel: 'N3', + surface: '猫', + }); + + assert.equal(computeWordClass(token), 'word word-known word-maturity-young word-jlpt-n3'); +}); + +test('computeWordClass gives n+1 and name matches precedence over maturity', () => { + const nPlusOne = createToken({ + isKnown: true, + knownMaturity: 'mature', + isNPlusOneTarget: true, + surface: '犬', + }); + assert.equal(computeWordClass(nPlusOne), 'word word-n-plus-one'); + + const nameMatch = createToken({ + isKnown: true, + knownMaturity: 'mature', + surface: 'アクア', + }) as MergedToken & { isNameMatch?: boolean }; + nameMatch.isNameMatch = true; + assert.equal( + computeWordClass(nameMatch, { nameMatchEnabled: true }), + 'word word-name-match', + ); +}); diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 00383311..d7759e40 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -1445,3 +1445,70 @@ test('secondary subtitle root CSS caps height so hover-pause band stays a top st assert.match(secondaryRootBlock, /max-height:\s*6em;/); assert.match(secondaryRootBlock, /overflow:\s*hidden;/); }); + +test('applySubtitleStyle sets known-word maturity color variables', () => { + const restoreDocument = installFakeDocument(); + try { + const subtitleRoot = new FakeElement('div'); + const subtitleContainer = new FakeElement('div'); + const secondarySubRoot = new FakeElement('div'); + const secondarySubContainer = new FakeElement('div'); + const ctx = { + state: createRendererState(), + dom: { + subtitleRoot, + subtitleContainer, + secondarySubRoot, + secondarySubContainer, + }, + } as never; + + const renderer = createSubtitleRenderer(ctx); + renderer.applySubtitleStyle({ + knownWordMaturityColors: { + new: '#111111', + learning: '#222222', + young: '#333333', + mature: '#444444', + }, + } as never); + + const values = (subtitleRoot.style as unknown as { values?: Map }).values; + assert.equal(values?.get('--subtitle-maturity-new-color'), '#111111'); + assert.equal(values?.get('--subtitle-maturity-learning-color'), '#222222'); + assert.equal(values?.get('--subtitle-maturity-young-color'), '#333333'); + assert.equal(values?.get('--subtitle-maturity-mature-color'), '#444444'); + } finally { + restoreDocument(); + } +}); + +test('applySubtitleStyle falls back to default maturity colors', () => { + const restoreDocument = installFakeDocument(); + try { + const subtitleRoot = new FakeElement('div'); + const subtitleContainer = new FakeElement('div'); + const secondarySubRoot = new FakeElement('div'); + const secondarySubContainer = new FakeElement('div'); + const ctx = { + state: createRendererState(), + dom: { + subtitleRoot, + subtitleContainer, + secondarySubRoot, + secondarySubContainer, + }, + } as never; + + const renderer = createSubtitleRenderer(ctx); + renderer.applySubtitleStyle({} as never); + + const values = (subtitleRoot.style as unknown as { values?: Map }).values; + assert.equal(values?.get('--subtitle-maturity-new-color'), '#ee99a0'); + assert.equal(values?.get('--subtitle-maturity-learning-color'), '#b7bdf8'); + assert.equal(values?.get('--subtitle-maturity-young-color'), '#91d7e3'); + assert.equal(values?.get('--subtitle-maturity-mature-color'), '#a6da95'); + } finally { + restoreDocument(); + } +}); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 181aee4e..9e1535b4 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -597,6 +597,11 @@ export function computeWordClass( classes.push('word-n-plus-one'); } else if (token.isKnown) { classes.push('word-known'); + // The maturity class rides on word-known so hover/selection rules keyed + // on word-known keep applying; it only overrides the color. + if (token.knownMaturity) { + classes.push(`word-maturity-${token.knownMaturity}`); + } } if (!hasPrioritizedNameMatch(token, resolvedTokenRenderSettings) && token.jlptLevel) { @@ -867,11 +872,39 @@ export function createSubtitleRenderer(ctx: RendererContext) { : {}), }; + const maturityColorOverrides = style.knownWordMaturityColors; + const maturityColors = { + new: sanitizeHexColor(maturityColorOverrides?.new, ctx.state.knownWordMaturityNewColor), + learning: sanitizeHexColor( + maturityColorOverrides?.learning, + ctx.state.knownWordMaturityLearningColor, + ), + young: sanitizeHexColor(maturityColorOverrides?.young, ctx.state.knownWordMaturityYoungColor), + mature: sanitizeHexColor( + maturityColorOverrides?.mature, + ctx.state.knownWordMaturityMatureColor, + ), + }; + ctx.state.knownWordColor = knownWordColor; + ctx.state.knownWordMaturityNewColor = maturityColors.new; + ctx.state.knownWordMaturityLearningColor = maturityColors.learning; + ctx.state.knownWordMaturityYoungColor = maturityColors.young; + ctx.state.knownWordMaturityMatureColor = maturityColors.mature; ctx.state.nPlusOneColor = nPlusOneColor; ctx.state.nameMatchEnabled = nameMatchEnabled; ctx.state.nameMatchColor = nameMatchColor; ctx.dom.subtitleRoot.style.setProperty('--subtitle-known-word-color', knownWordColor); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-maturity-new-color', maturityColors.new); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-maturity-learning-color', + maturityColors.learning, + ); + ctx.dom.subtitleRoot.style.setProperty('--subtitle-maturity-young-color', maturityColors.young); + ctx.dom.subtitleRoot.style.setProperty( + '--subtitle-maturity-mature-color', + maturityColors.mature, + ); ctx.dom.subtitleRoot.style.setProperty('--subtitle-n-plus-one-color', nPlusOneColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-name-match-color', nameMatchColor); ctx.dom.subtitleRoot.style.setProperty('--subtitle-hover-token-color', hoverTokenColor); diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index bcdc25ce..73895167 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -54,6 +54,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ 'anki.autoUpdateNewCards', 'subtitle.annotation.knownWords.highlightEnabled', + 'subtitle.annotation.knownWords.maturityEnabled', 'subtitle.annotation.nPlusOne', 'subtitle.annotation.jlpt', 'subtitle.annotation.frequency', diff --git a/src/types/anki.ts b/src/types/anki.ts index 8ebd604a..d38c70eb 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -82,6 +82,8 @@ export interface AnkiConnectConfig { }; knownWords?: { highlightEnabled?: boolean; + maturityEnabled?: boolean; + matureThresholdDays?: number; refreshMinutes?: number; addMinedWordsImmediately?: boolean; matchMode?: NPlusOneMatchMode; diff --git a/src/types/config.ts b/src/types/config.ts index a814ebd6..f6a762e3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -254,6 +254,8 @@ export interface ResolvedConfig { }; knownWords: { highlightEnabled: boolean; + maturityEnabled: boolean; + matureThresholdDays: number; refreshMinutes: number; addMinedWordsImmediately: boolean; matchMode: NPlusOneMatchMode; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index 97c55162..9c2b423a 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -1,6 +1,7 @@ export type RuntimeOptionId = | 'anki.autoUpdateNewCards' | 'subtitle.annotation.knownWords.highlightEnabled' + | 'subtitle.annotation.knownWords.maturityEnabled' | 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency' diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 2535c6ea..332af1d8 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -39,6 +39,8 @@ export interface MergedToken { pos3?: string; isMerged: boolean; isKnown: boolean; + /** Anki card maturity for a known token; unset when tier data is unavailable. */ + knownMaturity?: KnownWordMaturityTier; isNPlusOneTarget: boolean; /** * Text Yomitan had no dictionary entry for (e.g. ぅ~ elongation runs, @@ -61,6 +63,9 @@ export type FrequencyDictionaryLookup = (term: string) => number | null; export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5'; +/** Anki card maturity tier for a known word, most mature card wins. */ +export type KnownWordMaturityTier = 'new' | 'learning' | 'young' | 'mature'; + export interface SubtitlePosition { yPercent: number; } @@ -112,6 +117,12 @@ export interface SubtitleStyleConfig { backgroundColor?: string; nPlusOneColor?: string; knownWordColor?: string; + knownWordMaturityColors?: { + new: string; + learning: string; + young: string; + mature: string; + }; jlptColors?: { N1: string; N2: string;