diff --git a/config.example.jsonc b/config.example.jsonc index 0938591d..6768f57f 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -433,6 +433,12 @@ "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. + "knownWordMaturityColors": { + "new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled. + "learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled. + "young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled. + "mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled. + }, // Known word maturity colors setting. "jlptColors": { "N1": "#ed8796", // N1 setting. "N2": "#f5a97f", // N2 setting. @@ -569,6 +575,8 @@ }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false + "maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false + "matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21). "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 0938591d..6768f57f 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -433,6 +433,12 @@ "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. + "knownWordMaturityColors": { + "new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled. + "learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled. + "young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled. + "mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled. + }, // Known word maturity colors setting. "jlptColors": { "N1": "#ed8796", // N1 setting. "N2": "#f5a97f", // N2 setting. @@ -569,6 +575,8 @@ }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false + "maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false + "matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21). "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface diff --git a/src/anki-integration/known-word-cache-maturity.test.ts b/src/anki-integration/known-word-cache-maturity.test.ts index 6128af49..7eedd3e8 100644 --- a/src/anki-integration/known-word-cache-maturity.test.ts +++ b/src/anki-integration/known-word-cache-maturity.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import type { AnkiConnectConfig } from '../types/anki'; +import { setLogLevel } from '../logger'; import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache'; interface HarnessNoteInfo { @@ -255,8 +256,14 @@ test('getKnownWordTier returns null and skips tier queries when maturity is disa test('refresh preserves known-word cache when maturity lookup fails', async () => { const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig()); + const originalInfo = console.info; + const infoLogs: string[] = []; + setLogLevel('info'); try { + console.info = (...args: unknown[]) => { + infoLogs.push(args.map((value) => String(value)).join(' ')); + }; clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.failedQueries.add('deck:"Mining" prop:ivl>=21'); clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; @@ -271,7 +278,10 @@ test('refresh preserves known-word cache when maturity lookup fails', async () = }; assert.equal(persisted.version, 4); assert.deepEqual(persisted.tiers, {}); + assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/); } finally { + console.info = originalInfo; + setLogLevel(undefined); cleanup(); } }); diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts index 6c130850..5a9c2b71 100644 --- a/src/anki-integration/known-word-cache.ts +++ b/src/anki-integration/known-word-cache.ts @@ -389,8 +389,10 @@ export class KnownWordCacheManager { this.isRefreshingKnownWords = true; try { const noteFieldsById = await this.fetchKnownWordNoteFieldsById(); + const maturityTrackingEnabled = this.isMaturityTrackingEnabled(); + let maturityFetchFailed = false; let tierSets = null; - if (this.isMaturityTrackingEnabled()) { + if (maturityTrackingEnabled) { try { tierSets = await fetchKnownWordMaturityTierSets( (query, options) => this.deps.client.findNotes(query, options), @@ -398,6 +400,7 @@ export class KnownWordCacheManager { getMatureIntervalThresholdDays(this.deps.getConfig()), ); } catch (error) { + maturityFetchFailed = true; log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message); } } @@ -441,7 +444,11 @@ export class KnownWordCacheManager { 'Known-word cache refreshed', `noteCount=${currentNoteIds.length}`, `wordCount=${this.wordReadingNoteIds.size}`, - tierSets ? `maturityTiers=${this.noteTierById.size}` : 'maturityTiers=off', + tierSets + ? `maturityTiers=${this.noteTierById.size}` + : maturityFetchFailed + ? 'maturityTiers=fetch-failed' + : 'maturityTiers=off', ); } catch (error) { log.warn('Failed to refresh known-word cache:', (error as Error).message); diff --git a/src/anki-integration/known-word-maturity.test.ts b/src/anki-integration/known-word-maturity.test.ts index f04a1bae..d6be6314 100644 --- a/src/anki-integration/known-word-maturity.test.ts +++ b/src/anki-integration/known-word-maturity.test.ts @@ -35,20 +35,11 @@ test('maturity is enabled only when both highlight and maturity flags are on', ( 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: 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({ matureThresholdDays: Number.NaN })), 21); assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21); assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21); }); diff --git a/src/core/services/tokenizer/annotation-stage-maturity.test.ts b/src/core/services/tokenizer/annotation-stage-maturity.test.ts index 49541295..591bebd1 100644 --- a/src/core/services/tokenizer/annotation-stage-maturity.test.ts +++ b/src/core/services/tokenizer/annotation-stage-maturity.test.ts @@ -80,9 +80,7 @@ test('annotateTokens resolves maturity through the kana reading fallback', () => reading: string | undefined; allowReadingOnlyMatch: boolean | undefined; }> = []; - const tokens = [ - makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 }), - ]; + const tokens = [makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 })]; const result = annotateTokens( tokens, diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 558e065d..a0cca85a 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -646,7 +646,9 @@ function computeTokenKnownMaturity( if (!fallbackReading || fallbackReading === matchText.trim()) { return undefined; } - return getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined; + return ( + getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined + ); } function filterTokenFrequencyRank( diff --git a/src/renderer/subtitle-render-word-class.test.ts b/src/renderer/subtitle-render-word-class.test.ts index e151620a..205e5d34 100644 --- a/src/renderer/subtitle-render-word-class.test.ts +++ b/src/renderer/subtitle-render-word-class.test.ts @@ -250,8 +250,5 @@ test('computeWordClass gives n+1 and name matches precedence over maturity', () surface: 'アクア', }) as MergedToken & { isNameMatch?: boolean }; nameMatch.isNameMatch = true; - assert.equal( - computeWordClass(nameMatch, { nameMatchEnabled: true }), - 'word word-name-match', - ); + assert.equal(computeWordClass(nameMatch, { nameMatchEnabled: true }), 'word word-name-match'); });