feat(overlay): Anki maturity-based known-word highlighting

Color known-word subtitle highlights by Anki card maturity (new,
learning, young, mature) like asbplayer (#171). Notes are classified
server-side with Anki search filters (prop:ivl, is:learn) during the
known-word cache refresh, so no per-card data is fetched. A word's tier
is its most mature matching card/note, with the same reading-aware
matching as boolean known-word lookups.

- known-word cache v4 state persists per-note tiers; lifecycle key only
  gains the maturity field while enabled so existing caches survive
- ankiConnect.knownWords.maturityEnabled + matureThresholdDays (21)
- subtitleStyle.knownWordMaturityColors with catppuccin defaults
- word-maturity-<tier> class rides on word-known so hover/selection
  rules keep applying; falls back to knownWordColor without tier data
- runtime toggle subtitle.annotation.knownWords.maturityEnabled
This commit is contained in:
2026-07-17 23:41:40 -07:00
parent e223cf9b71
commit 8a8a700ccb
31 changed files with 1238 additions and 47 deletions
+207 -44
View File
@@ -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<string, unknown> = {
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<string, KnownWordEntry[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
interface KnownWordCacheStateV4 {
readonly version: 4;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
readonly tiers: Record<string, KnownWordMaturityTier>;
}
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<string, Map<string, number>>();
// hiragana reading → note count, so kana tokens still match by reading alone.
private readingCounts = new Map<string, number>();
private wordReadingNoteIds = new Map<string, Map<string, Set<number>>>();
// hiragana reading → note ids, so kana tokens still match by reading alone.
private readingNoteIds = new Map<string, Set<number>>();
private noteEntriesById = new Map<number, KnownWordEntry[]>();
private noteTierById = new Map<number, KnownWordMaturityTier>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | 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<number>,
): 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<void> {
@@ -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<string, KnownWordEntry[]> = {};
const tiers: Record<string, KnownWordMaturityTier> = {};
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<string, unknown>;
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 ||