Anki maturity-based known-word highlighting (#172)

This commit is contained in:
2026-07-27 23:58:02 -07:00
committed by GitHub
parent 08c6807cb1
commit 987b325edb
54 changed files with 3046 additions and 381 deletions
+9
View File
@@ -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;
}
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
KnownWordCacheState,
knownWordsFromState,
parseKnownWordCacheState,
} from './known-word-cache-format';
function parseOrThrow(value: unknown): KnownWordCacheState {
const parsed = parseKnownWordCacheState(value);
assert.ok(parsed, 'expected the payload to parse');
return parsed;
}
const BASE = { refreshedAtMs: 1, scope: 'deck:test' };
test('known-word cache format reads words from every version the union covers', () => {
assert.deepEqual(
knownWordsFromState(parseOrThrow({ ...BASE, version: 1, words: ['する'] })),
new Set(['する']),
);
assert.deepEqual(
knownWordsFromState(
parseOrThrow({ ...BASE, version: 2, words: ['する'], notes: { '1': ['する'] } }),
),
new Set(['する']),
);
assert.deepEqual(
knownWordsFromState(
parseOrThrow({
...BASE,
version: 3,
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
}),
),
new Set(['する', '猫']),
);
// v4 only adds `tiers`; the words a reader sees must not change.
assert.deepEqual(
knownWordsFromState(
parseOrThrow({
...BASE,
version: 4,
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
tiers: { '1': 'mature', '2': 'young' },
}),
),
new Set(['する', '猫']),
);
});
test('known-word cache format rejects payloads that are not a known cache state', () => {
const notes = { '1': [{ word: 'する', reading: null }] };
assert.equal(parseKnownWordCacheState(null), null);
assert.equal(parseKnownWordCacheState('{}'), null);
// An unknown version must not be read as an empty cache.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 99, notes }), null);
assert.equal(
parseKnownWordCacheState({ version: 4, scope: 'deck:test', notes, tiers: {} }),
null,
);
assert.equal(parseKnownWordCacheState({ version: 4, refreshedAtMs: 1, notes, tiers: {} }), null);
// v4 without its tiers map is a v3 payload mislabelled as v4.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 4, notes }), null);
// v3 carries entry objects, not the bare strings v2 used.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 3, notes: { '1': ['する'] } }), null);
});
@@ -0,0 +1,141 @@
// On-disk shape of the known-word cache, plus the only parser for it.
//
// Two processes read this file: the cache manager (which rebuilds its indexes
// from it) and the stats server (which counts known words). They used to carry
// separate hand-written parsers, so bumping the format to v4 for maturity tiers
// left the stats server silently reporting zero known words. Everything that
// touches the format now goes through here, and the version dispatch below ends
// in assertNever so adding a V5 to the union fails the build at every consumer
// instead of degrading to an empty result at runtime.
import type { KnownWordMaturityTier } from '../types/subtitle';
import type { KnownWordEntry } from './known-word-entries';
export interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
export interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
export interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
export interface KnownWordCacheStateV4 {
readonly version: 4;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
readonly tiers: Record<string, KnownWordMaturityTier>;
}
export type KnownWordCacheState =
| KnownWordCacheStateV1
| KnownWordCacheStateV2
| KnownWordCacheStateV3
| KnownWordCacheStateV4;
// Version written by persistKnownWordCacheState. Readers accept every version
// in the union above; only the writer pins one.
export type CurrentKnownWordCacheState = KnownWordCacheStateV4;
// Exported so every consumer that switches on `version` can close its dispatch
// the same way: a new member of the union becomes a type error at each call
// site rather than a case that silently falls through.
export function assertNever(value: never): never {
throw new Error(`Unhandled known-word cache state: ${JSON.stringify(value)}`);
}
function isEntryRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isKnownWordEntry(entry: unknown): boolean {
if (!isEntryRecord(entry)) return false;
const candidate = entry as Partial<KnownWordEntry>;
return (
typeof candidate.word === 'string' &&
(candidate.reading === null || typeof candidate.reading === 'string')
);
}
// Returns the narrowed state, or null when the payload is not a cache state we
// recognize. Per-entry values that are merely unusable (an unknown maturity
// tier, a non-numeric note id) are dropped by callers at load time rather than
// rejecting the whole file.
export function parseKnownWordCacheState(value: unknown): KnownWordCacheState | null {
if (!isEntryRecord(value)) return null;
const candidate = value;
if (
candidate.version !== 1 &&
candidate.version !== 2 &&
candidate.version !== 3 &&
candidate.version !== 4
) {
return null;
}
if (typeof candidate.refreshedAtMs !== 'number') return null;
if (typeof candidate.scope !== 'string') return null;
if (candidate.version === 1 || candidate.version === 2) {
if (!Array.isArray(candidate.words)) return null;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) return null;
}
if (candidate.version === 4) {
// Per-tier values are sanitized entry-by-entry at load time.
if (!isEntryRecord(candidate.tiers)) return null;
}
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
if (!isEntryRecord(candidate.notes)) return null;
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: isKnownWordEntry;
if (
!Object.values(candidate.notes).every(
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return null;
}
}
return candidate as unknown as KnownWordCacheState;
}
// Every word the cache considers known, flattened across notes. Consumers that
// only need membership (the stats server) use this instead of walking the
// version-specific layout themselves.
export function knownWordsFromState(state: KnownWordCacheState): Set<string> {
switch (state.version) {
case 1:
case 2:
return new Set(state.words);
case 3:
case 4: {
const words = new Set<string>();
for (const entries of Object.values(state.notes)) {
for (const entry of entries) {
if (entry.word) words.add(entry.word);
}
}
return words;
}
default:
return assertNever(state);
}
}
@@ -0,0 +1,411 @@
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 { setLogLevel } from '../logger';
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';
interface HarnessNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
function createMaturityHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: { findNotes: number; notesInfo: number; queries: string[] };
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: HarnessNoteInfo[];
findNotesByQuery: Map<string, number[]>;
failedQueries: Set<string>;
};
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<string, number[]>(),
failedQueries: new Set<string>(),
};
const deps = {
client: {
findNotes: async (query: string) => {
calls.findNotes += 1;
calls.queries.push(query);
if (clientState.failedQueries.has(query)) {
throw new Error(`Anki unavailable for query: ${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 });
},
};
}
// The four queries a maturity refresh issues, in one place so a query-string
// change lands in a single spot.
function setTierQueries(
clientState: { findNotesByQuery: Map<string, number[]> },
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
): void {
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
}
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): 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,"maturityRules":2}',
);
const customThreshold: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
maturityEnabled: true,
matureThresholdDays: 30,
refreshMinutes: 60,
},
};
assert.equal(
getKnownWordCacheLifecycleConfig(customThreshold),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
);
});
test('a cache built under the old tier rules is invalidated', () => {
const config: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
};
// v1 rules put lapsed cards in young because the interval queries did not
// exclude is:learn; those persisted tiers must not be served under v2.
assert.notEqual(
getKnownWordCacheLifecycleConfig(config),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
);
});
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [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 {
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [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 {
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [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 {
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [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 {
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
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('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 -is:learn');
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), null);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
tiers: Record<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, {});
assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
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;
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [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<string, string>;
};
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();
}
});
test('appendFromNoteInfo preserves an existing maturity tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
clientState.notesInfoResult = [
{ noteId: 7, fields: { Word: { value: '猫' } } },
{ noteId: 8, fields: { Word: { value: '犬' } } },
];
await manager.refresh(true);
manager.appendFromNoteInfo({
noteId: 7,
fields: { Word: { value: '子猫' } },
});
manager.appendFromNoteInfo({
noteId: 8,
fields: { Word: { value: '子犬' } },
});
assert.equal(manager.getKnownWordTier('子猫'), 'mature');
assert.equal(manager.getKnownWordTier('子犬'), 'learning');
} finally {
cleanup();
}
});
test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
{ noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
];
await manager.refresh(true);
// Same matching rules as getKnownWordTier, so an audit can re-derive the
// rendered tier from the exact notes that produced it.
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]);
assert.deepEqual(
[
...manager.getKnownWordMatchNoteIds('けいこく', undefined, {
allowReadingOnlyMatch: false,
}),
],
[],
);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []);
} finally {
cleanup();
}
});
@@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
version: number;
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.version, 3);
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.notes, {
'1': [{ word: '鳥', reading: null }],
});
+259 -144
View File
@@ -4,7 +4,22 @@ 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 {
KNOWN_WORD_MATURITY_RULES_VERSION,
classifyKnownWordNoteTier,
fetchKnownWordMaturityTierSets,
getKnownWordMaturityEnabled,
getMatureIntervalThresholdDays,
maxKnownWordMaturityTier,
sanitizeKnownWordMaturityTier,
} from './known-word-maturity';
import {
CurrentKnownWordCacheState,
assertNever,
parseKnownWordCacheState,
} from './known-word-cache-format';
import {
DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry,
@@ -62,11 +77,21 @@ 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 fields are only added while enabled so persisted caches from
// before the feature existed (or with it off) keep their identity.
// maturityRules is the classification-rule version: bump it whenever the tier
// queries change meaning so existing caches refetch instead of serving tiers
// computed under the old rules.
if (getKnownWordMaturityEnabled(config)) {
payload.maturity = getMatureIntervalThresholdDays(config);
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
}
return JSON.stringify(payload);
}
export interface KnownWordCacheNoteInfo {
@@ -74,30 +99,6 @@ export interface KnownWordCacheNoteInfo {
fields: Record<string, { value: string }>;
}
interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
const NO_READING_KEY = '';
interface KnownWordCacheClient {
@@ -125,12 +126,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 +158,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 +170,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 +184,86 @@ 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;
}
return this.maxTierForNotes(null, this.getKnownWordMatchNoteIds(text, reading, options));
}
// Note ids a known-word lookup matches, using the same matching rules as
// getKnownWordTier. Exposed for diagnostics (see
// scripts/verify-known-word-highlights.ts), which audits a rendered tier
// against the live card data of the notes that produced it.
getKnownWordMatchNoteIds(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): Set<number> {
const matches = new Set<number>();
const normalized = this.normalizeKnownWordForLookup(text);
if (normalized.length === 0) {
return matches;
}
const knownReadings = this.wordReadingNoteIds.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
if (normalizedReading.length === 0) {
for (const noteIds of knownReadings.values()) {
for (const noteId of noteIds) {
matches.add(noteId);
}
}
return matches;
}
for (const key of [NO_READING_KEY, normalizedReading]) {
for (const noteId of knownReadings.get(key) ?? []) {
matches.add(noteId);
}
}
return matches;
}
if (options?.allowReadingOnlyMatch === false) {
return matches;
}
const hiragana = convertKatakanaToHiragana(normalized);
if ([...hiragana].length === 1) {
return matches;
}
for (const noteId of this.readingNoteIds.get(hiragana) ?? []) {
matches.add(noteId);
}
return matches;
}
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 +310,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 +328,15 @@ export class KnownWordCacheManager {
return didMutateCache;
}
// A just-mined card has never been reviewed.
if (
this.isMaturityTrackingEnabled() &&
this.noteEntriesById.has(noteInfo.noteId) &&
!this.noteTierById.has(noteInfo.noteId)
) {
this.noteTierById.set(noteInfo.noteId, 'new');
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
@@ -290,6 +380,21 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true;
try {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const maturityTrackingEnabled = this.isMaturityTrackingEnabled();
let maturityFetchFailed = false;
let tierSets = null;
if (maturityTrackingEnabled) {
try {
tierSets = await fetchKnownWordMaturityTierSets(
(query, options) => this.deps.client.findNotes(query, options),
this.getKnownWordQueryScopes().map((scope) => scope.query),
getMatureIntervalThresholdDays(this.deps.getConfig()),
);
} catch (error) {
maturityFetchFailed = true;
log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message);
}
}
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteEntriesById.size === 0) {
@@ -316,13 +421,25 @@ 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}`
: maturityFetchFailed
? 'maturityTiers=fetch-failed'
: 'maturityTiers=off',
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -337,6 +454,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 +714,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 +731,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;
}
@@ -675,8 +811,8 @@ export class KnownWordCacheManager {
return;
}
const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) {
const parsed = parseKnownWordCacheState(JSON.parse(raw) as unknown);
if (!parsed) {
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
@@ -689,48 +825,63 @@ export class KnownWordCacheManager {
}
this.clearInMemoryState();
if (parsed.version === 3) {
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
switch (parsed.version) {
case 1:
// v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
case 2:
// Older states have no readings; load them reading-less (fail-open,
// matching the old behavior) but leave the cache marked stale so the
// next refresh upgrades entries with readings from Anki.
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({
word: this.normalizeKnownWordForLookup(word),
reading: null,
})),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries);
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
this.knownWordsStateKey = parsed.scope;
return;
case 3:
case 4:
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries);
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
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;
return;
default:
assertNever(parsed);
}
if (parsed.version === 2) {
// Older states have no readings; load them reading-less (fail-open,
// matching the old behavior) but leave the cache marked stale so the
// next refresh upgrades entries with readings from Anki.
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsStateKey = parsed.scope;
return;
}
// v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
} catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message);
this.clearInMemoryState();
@@ -741,17 +892,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: CurrentKnownWordCacheState = {
version: 4,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey,
notes,
tiers,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) {
@@ -759,48 +916,6 @@ 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) {
return false;
}
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (candidate.version !== 3) {
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 (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
Array.isArray(candidate.notes)
) {
return false;
}
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: (entry: unknown): boolean =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as KnownWordEntry).word === 'string' &&
((entry as KnownWordEntry).reading === null ||
typeof (entry as KnownWordEntry).reading === 'string');
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return false;
}
}
return true;
}
private extractKnownWordEntriesFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
@@ -0,0 +1,105 @@
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 -is:learn');
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn');
assert.equal(queries.learning, 'deck:"Mining" is:learn');
});
test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
// A lapsed card keeps an interval of at least the lapse minInt (>= 1), so
// without the exclusion the young query would claim every relearning card
// and the learning tier could only ever match brand-new cards mid-step.
for (const intervalQuery of [queries.mature, queries.young]) {
assert.ok(intervalQuery.includes('-is:learn'));
}
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 -is:learn');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn');
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);
});
+112
View File
@@ -0,0 +1,112 @@
import type { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle';
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
// Version of the tier classification rules; part of the known-word cache
// identity so a rule change invalidates caches built under the old rules.
export const KNOWN_WORD_MATURITY_RULES_VERSION = 2;
// 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<number>;
young: ReadonlySet<number>;
learning: ReadonlySet<number>;
}
// 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.
//
// The interval tiers exclude is:learn so the per-card buckets stay disjoint and
// match Anki's own card counts, where (re)learning is its own bucket rather
// than part of young/mature. Without the exclusion a lapsed card - whose
// interval is reset to at least lapse minInt, so >= 1 - is caught by the young
// query first and the learning tier becomes unreachable in practice.
export function buildKnownWordMaturityTierQueries(
scopeQuery: string,
thresholdDays: number,
): KnownWordMaturityTierQueries {
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
return {
mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
learning: `${prefix}is:learn`,
};
}
export async function fetchKnownWordMaturityTierSets(
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>,
scopeQueries: string[],
thresholdDays: number,
): Promise<{ mature: Set<number>; young: Set<number>; learning: Set<number> }> {
const sets = {
mature: new Set<number>(),
young: new Set<number>(),
learning: new Set<number>(),
};
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;
}
+1
View File
@@ -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',
@@ -60,6 +60,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
knownWords: {
highlightEnabled: false,
maturityEnabled: false,
matureThresholdDays: 21,
refreshMinutes: 1440,
addMinedWordsImmediately: true,
matchMode: 'headword',
@@ -32,6 +32,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
backdropFilter: 'blur(6px)',
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
@@ -295,6 +295,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.',
},
{
path: 'ankiConnect.knownWords.maturityEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
description:
'Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting.',
},
{
path: 'ankiConnect.knownWords.matureThresholdDays',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.knownWords.matureThresholdDays,
description:
'Card interval in days at which a known word counts as mature (Anki convention: 21).',
},
{
path: 'ankiConnect.knownWords.refreshMinutes',
kind: 'number',
@@ -109,6 +109,34 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
description: 'Color used for the single N+1 target token subtitle highlight.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.new',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.new,
description:
'Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.learning',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.learning,
description:
'Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.young',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.young,
description:
'Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.mature',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.mature,
description:
'Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
+16
View File
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'subtitle.annotation.knownWords.maturityEnabled',
path: 'ankiConnect.knownWords.maturityEnabled',
label: 'Known Word Maturity Colors',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
knownWords: {
maturityEnabled: value === true,
},
}),
},
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.enabled',
+51
View File
@@ -328,3 +328,54 @@ test('warns and falls back for invalid proxy settings', () => {
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
});
test('resolves knownWords maturity settings from config', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true, maturityEnabled: true, matureThresholdDays: 30 },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.maturityEnabled, true);
assert.equal(context.resolved.ankiConnect.knownWords.matureThresholdDays, 30);
assert.deepEqual(warnings, []);
});
test('warns and falls back for invalid knownWords maturity settings', () => {
const { context, warnings } = makeContext({
knownWords: { maturityEnabled: 'yes', matureThresholdDays: 0 },
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.maturityEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.maturityEnabled'));
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matureThresholdDays'),
);
});
test('omitted knownWords maturity settings fall back to defaults', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.maturityEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
);
assert.deepEqual(warnings, []);
});
@@ -49,6 +49,46 @@ export function applyAnkiKnownWordsResolution(
}
}
const knownWordsMaturityEnabled = asBoolean(knownWordsConfig.maturityEnabled);
if (knownWordsMaturityEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.maturityEnabled = knownWordsMaturityEnabled;
} else if (hasOwn(knownWordsConfig, 'maturityEnabled')) {
context.warn(
'ankiConnect.knownWords.maturityEnabled',
knownWordsConfig.maturityEnabled,
context.resolved.ankiConnect.knownWords.maturityEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.maturityEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
} else {
context.resolved.ankiConnect.knownWords.maturityEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
}
const knownWordsMatureThresholdDays = asNumber(knownWordsConfig.matureThresholdDays);
const hasValidMatureThresholdDays =
knownWordsMatureThresholdDays !== undefined &&
Number.isInteger(knownWordsMatureThresholdDays) &&
knownWordsMatureThresholdDays >= 1;
if (hasOwn(knownWordsConfig, 'matureThresholdDays')) {
if (hasValidMatureThresholdDays) {
context.resolved.ankiConnect.knownWords.matureThresholdDays = knownWordsMatureThresholdDays;
} else {
context.warn(
'ankiConnect.knownWords.matureThresholdDays',
knownWordsConfig.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
'Expected an integer of at least 1.',
);
context.resolved.ankiConnect.knownWords.matureThresholdDays =
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
}
} else {
context.resolved.ankiConnect.knownWords.matureThresholdDays =
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
+26
View File
@@ -125,6 +125,32 @@ test('settings registry groups annotation display fields by config group', () =>
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
});
test('settings registry groups maturity settings under Known Words with color controls', () => {
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
for (const tier of ['new', 'learning', 'young', 'mature']) {
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
assert.equal(tierField.subsection, 'Known Words');
assert.equal(tierField.control, 'color');
}
});
test('settings registry orders maturity colors from least mature to mature', () => {
const knownWordsPaths = fields
.filter((candidate) => candidate.subsection === 'Known Words')
.map((candidate) => candidate.configPath);
assert.deepEqual(knownWordsPaths, [
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.maturityEnabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
]);
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
+11
View File
@@ -163,6 +163,12 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
@@ -353,6 +359,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' ||
@@ -516,6 +523,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'key-code';
}
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
@@ -533,6 +541,9 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.maturityEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.matureThresholdDays') return 'Known Words';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
@@ -497,6 +497,8 @@ describe('stats server API routes', () => {
cachePath,
JSON.stringify({
version: 1,
refreshedAtMs: 1,
scope: 'deck:test',
words: ['する'],
}),
);
@@ -561,6 +563,69 @@ describe('stats server API routes', () => {
});
});
it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({
version: 4,
refreshedAtMs: 1,
scope: 'deck:test',
notes: {
'101': [{ word: 'する', reading: 'する' }],
'102': [{ word: '猫', reading: null }],
},
tiers: { '101': 'mature', '102': 'young' },
}),
);
const app = createStatsApp(
createMockTracker({
getSessionWordsByLine: async (sessionId: number) =>
sessionId === 1
? [
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
]
: [],
}),
{ knownWordCachePath: cachePath },
);
const res = await app.request('/api/stats/sessions?limit=5');
assert.equal(res.status, 200);
const body = await res.json();
const first = body[0];
assert.equal(first.knownWordsSeen, 2);
assert.equal(first.knownWordRate, 66.7);
});
});
it('GET /api/stats/sessions reports no known words when the cache format is unrecognized', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }),
);
const app = createStatsApp(
createMockTracker({
getSessionWordsByLine: async () => [
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
],
}),
{ knownWordCachePath: cachePath },
);
const res = await app.request('/api/stats/sessions?limit=5');
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body[0].knownWordsSeen, 0);
});
});
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
let seenSessionId = 0;
let seenLimit = 0;
@@ -609,6 +674,8 @@ describe('stats server API routes', () => {
cachePath,
JSON.stringify({
version: 1,
refreshedAtMs: 1,
scope: 'deck:test',
words: ['知る', '猫'],
}),
);
+14 -17
View File
@@ -6,11 +6,18 @@ import {
getConfiguredWordFieldName,
getPreferredNoteFieldValue,
} from '../../../anki-field-config.js';
import {
knownWordsFromState,
parseKnownWordCacheState,
} from '../../../anki-integration/known-word-cache-format.js';
import { createLogger } from '../../../logger.js';
import type { AnkiConnectConfig } from '../../../types.js';
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
const statsKnownWordsLogger = createLogger('stats:known-words');
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
@@ -84,24 +91,14 @@ export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | nul
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
try {
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
version?: number;
words?: string[];
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
};
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
return new Set(raw.words);
}
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
const words = new Set<string>();
for (const entries of Object.values(raw.notes)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word);
}
}
return words;
const state = parseKnownWordCacheState(JSON.parse(readFileSync(cachePath, 'utf-8')) as unknown);
if (!state) {
// A cache that exists but does not parse is a format mismatch, not an
// empty collection; say so instead of reporting zero known words.
statsKnownWordsLogger.warn(`Unrecognized known-word cache format at ${cachePath}`);
return null;
}
return knownWordsFromState(state);
} catch {
// Treat an unreadable cache as unavailable.
}
+15
View File
@@ -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<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: KnownWordLookupFn;
getKnownWordTier?: KnownWordTierLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -88,6 +96,7 @@ export interface TokenizerDepsRuntimeOptions {
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | 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,
@@ -0,0 +1,130 @@
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> = {}): MergedToken {
return {
surface: '猫',
reading: 'ネコ',
headword: '猫',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): 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<string, KnownWordMaturityTier>([['だいたい', '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);
});
@@ -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;
}
@@ -52,7 +63,7 @@ export interface AnnotationStageOptions {
sourceText?: string;
}
function resolveKnownWordText(
export function resolveKnownWordText(
surface: string,
headword: string,
matchMode: NPlusOneMatchMode,
@@ -560,7 +571,7 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
// known-word reading disambiguation and the reading fallback stay in sync if the
// validity rule changes.
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
export function resolveCompleteTokenReading(token: MergedToken): string | undefined {
const normalizedReading = token.reading.trim();
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
return undefined;
@@ -573,7 +584,7 @@ function resolveCompleteTokenReading(token: MergedToken): string | undefined {
// inflected surface's reading does not match the dictionary form's reading,
// and partial furigana readings (see isCompleteReadingForSurface) would cause
// false negatives. Undefined falls back to text-only matching (fail-open).
function resolveKnownWordReadingForMatch(
export function resolveKnownWordReadingForMatch(
token: MergedToken,
knownWordMatchMode: NPlusOneMatchMode,
): string | undefined {
@@ -616,6 +627,30 @@ 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<AnnotationStageDeps['getKnownWordTier']>,
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<string>,
@@ -677,6 +712,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 +736,7 @@ export function annotateTokens(
return {
...strippedToken,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
};
}
@@ -712,6 +753,7 @@ export function annotateTokens(
return {
...token,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank,
jlptLevel,
+2
View File
@@ -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);
@@ -82,6 +82,73 @@ test('manual selection store persists overrides and matches later episodes in th
});
});
test('manual selection store applies a season-directory override when later episode guesses differ', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directory = '/Volumes/jellyfin/anime/toaru-kagaku-no-railgun/Season-2';
const selectedKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 10 - Critical.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 10 - Critical.mkv',
guess: {
title: 'Critical',
season: null,
episode: 10,
source: 'guessit',
},
});
await store.setOverride({
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
const laterEpisodeKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 16 - Sisters.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 16 - Sisters.mkv',
guess: {
title: 'Sisters',
season: null,
episode: 16,
source: 'guessit',
},
});
assert.deepEqual(await store.getOverride(laterEpisodeKey), {
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store replaces older guessed keys for the same season directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directoryKey = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
const firstKey = `${directoryKey}--critical`;
const laterKey = `${directoryKey}--sisters`;
await store.setOverride({
seriesKey: firstKey,
mediaId: 1057,
mediaTitle: 'Ippatsu Kiki Musume',
staleMediaIds: [],
});
await store.setOverride({
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
assert.deepEqual(await store.getOverride(firstKey), {
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store resolves legacy unscoped override keys', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
@@ -171,6 +238,42 @@ test('manual selection store prefers exact scoped override over legacy fallback'
});
});
test('manual selection store prefers same-directory override over legacy fallback', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
const directoryScope = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: 'sisters',
mediaId: 1057,
mediaTitle: 'Legacy fallback',
staleMediaIds: [],
},
{
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
},
],
}),
'utf8',
);
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
assert.deepEqual(await store.getOverride(`${directoryScope}--sisters`), {
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store keeps overrides separate for different season directories', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
@@ -107,6 +107,11 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
}
function getDirectoryScope(seriesKey: string): string | null {
const scopedSeparatorIndex = seriesKey.indexOf('--');
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
@@ -135,7 +140,23 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
for (const candidate of candidates) {
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
if (exactMatch) return exactMatch;
const directoryScope = getDirectoryScope(seriesKey);
if (directoryScope) {
const scopedMatches = overrides.filter(
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
);
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
const latest = scopedMatches.at(-1)!;
return {
...latest,
staleMediaIds: dedupeNumbers(scopedMatches.flatMap((entry) => entry.staleMediaIds)),
};
}
}
for (const candidate of candidates.slice(1)) {
const match = overrides.find((entry) => entry.seriesKey === candidate);
if (match) return match;
}
@@ -146,8 +167,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
if (!normalized) {
throw new Error('Invalid character dictionary manual selection.');
}
const remaining = readOverrides(filePath).filter(
(entry) => entry.seriesKey !== normalized.seriesKey,
const directoryScope = getDirectoryScope(normalized.seriesKey);
const remaining = readOverrides(filePath).filter((entry) =>
directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope
: entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
},
@@ -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
? {
+59 -6
View File
@@ -2,6 +2,12 @@ import type { SessionHelpSection } from './session-help-sections';
export type SessionHelpSubtitleStyle = {
knownWordColor?: unknown;
knownWordMaturityColors?: {
new?: unknown;
learning?: unknown;
young?: unknown;
mature?: unknown;
};
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
@@ -13,10 +19,19 @@ export type SessionHelpSubtitleStyle = {
};
};
export type SessionHelpColorOptions = {
/** When true, known words are colored per Anki card maturity instead of one flat color. */
knownWordMaturityEnabled?: boolean;
};
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
knownWordMaturityNewColor: '#ee99a0',
knownWordMaturityLearningColor: '#b7bdf8',
knownWordMaturityYoungColor: '#91d7e3',
knownWordMaturityMatureColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
@@ -32,15 +47,53 @@ function normalizeColor(value: unknown, fallback: string): string {
return HEX_COLOR_RE.test(next) ? next : fallback;
}
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
function buildKnownWordRows(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions,
): SessionHelpSection['rows'] {
if (!options.knownWordMaturityEnabled) {
const knownWordColor = normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor);
return [{ shortcut: 'Known words', action: knownWordColor, color: knownWordColor }];
}
const maturityColors = style.knownWordMaturityColors;
const tiers: Array<{ label: string; value: unknown; fallback: string }> = [
{
label: 'Known words (new)',
value: maturityColors?.new,
fallback: FALLBACK_COLORS.knownWordMaturityNewColor,
},
{
label: 'Known words (learning)',
value: maturityColors?.learning,
fallback: FALLBACK_COLORS.knownWordMaturityLearningColor,
},
{
label: 'Known words (young)',
value: maturityColors?.young,
fallback: FALLBACK_COLORS.knownWordMaturityYoungColor,
},
{
label: 'Known words (mature)',
value: maturityColors?.mature,
fallback: FALLBACK_COLORS.knownWordMaturityMatureColor,
},
];
return tiers.map((tier) => {
const color = normalizeColor(tier.value, tier.fallback);
return { shortcut: tier.label, action: color, color };
});
}
export function buildColorSection(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions = {},
): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
...buildKnownWordRows(style, options),
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
+4 -1
View File
@@ -403,6 +403,7 @@ export function buildSessionHelpSections(input: {
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
knownWordMaturityEnabled?: boolean;
}): SessionHelpSection[] {
const sessionBindings = input.sessionBindings.filter((binding) => {
if (binding.actionType !== 'session-action') return true;
@@ -420,7 +421,9 @@ export function buildSessionHelpSections(input: {
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
}),
...buildFixedOverlaySections(),
buildColorSection(input.subtitleStyle ?? {}),
buildColorSection(input.subtitleStyle ?? {}, {
knownWordMaturityEnabled: input.knownWordMaturityEnabled,
}),
]);
}
+98
View File
@@ -9,7 +9,9 @@ import {
createSessionHelpModal,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
isKnownWordMaturityLegendEnabled,
} from './session-help.js';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
@@ -104,6 +106,101 @@ test('session help builds rows from canonical session bindings and fixed overlay
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
});
function booleanRuntimeOption(id: RuntimeOptionId, value: boolean): RuntimeOptionState {
return {
id,
label: id,
scope: 'subtitle',
valueType: 'boolean',
value,
allowedValues: [true, false],
requiresRestart: false,
};
}
test('maturity legend requires both known-word highlighting and maturity coloring', () => {
const highlightOn = booleanRuntimeOption('subtitle.annotation.knownWords.highlightEnabled', true);
const highlightOff = booleanRuntimeOption(
'subtitle.annotation.knownWords.highlightEnabled',
false,
);
const maturityOn = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', true);
const maturityOff = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOn]), true);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOff, maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOff]), false);
assert.equal(isKnownWordMaturityLegendEnabled([maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([]), false);
});
function colorLegendRows(input: Parameters<typeof buildSessionHelpSections>[0]) {
const sections = buildSessionHelpSections(input);
return sections.find((section) => section.title === 'Color legend')?.rows ?? [];
}
test('color legend shows the flat known-word color when maturity coloring is off', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
},
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[{ shortcut: 'Known words', action: '#a6da95', color: '#a6da95' }],
);
});
test('color legend swaps in maturity tiers when maturity coloring is on', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#f0c6c6',
},
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[
{ shortcut: 'Known words (new)', action: '#ee99a0', color: '#ee99a0' },
{ shortcut: 'Known words (learning)', action: '#b7bdf8', color: '#b7bdf8' },
{ shortcut: 'Known words (young)', action: '#91d7e3', color: '#91d7e3' },
{ shortcut: 'Known words (mature)', action: '#f0c6c6', color: '#f0c6c6' },
],
);
assert.ok(rows.some((row) => row.shortcut === 'N+1 words'));
});
test('color legend falls back to default maturity colors when overrides are invalid', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordMaturityColors: { new: 'not-a-color', learning: 42 },
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')).map((row) => row.color),
['#ee99a0', '#b7bdf8', '#91d7e3', '#a6da95'],
);
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
@@ -176,6 +273,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
getSubtitleSidebarSnapshot: async () => ({
config: { toggleKey: 'Backslash' },
}),
getRuntimeOptions: async () => [],
},
focus: () => {},
addEventListener: () => {},
+44 -10
View File
@@ -1,4 +1,5 @@
import type { ModalStateReader, RendererContext } from '../context';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
import {
buildSessionHelpSections,
type SessionHelpSection,
@@ -19,6 +20,32 @@ type SessionHelpBindingInfo = {
fallbackUnavailable: boolean;
};
/**
* Tiers only render when known-word highlighting is also on, matching
* getKnownWordMaturityEnabled in anki-integration/known-word-maturity.
*/
export function isKnownWordMaturityLegendEnabled(runtimeOptions: RuntimeOptionState[]): boolean {
const isOn = (id: RuntimeOptionId): boolean =>
runtimeOptions.some((option) => option.id === id && option.value === true);
return (
isOn('subtitle.annotation.knownWords.highlightEnabled') &&
isOn('subtitle.annotation.knownWords.maturityEnabled')
);
}
/**
* Maturity coloring is a live runtime toggle, so the color legend reads it from
* runtime options instead of the resolved subtitle style. A missing or failing
* runtime-options call falls back to the flat known-word color.
*/
async function readKnownWordMaturityEnabled(): Promise<boolean> {
try {
return isKnownWordMaturityLegendEnabled(await window.electronAPI.getRuntimeOptions());
} catch {
return false;
}
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
@@ -219,22 +246,29 @@ export function createSessionHelpModal(
async function render(): Promise<boolean> {
try {
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
]);
const [
sessionBindings,
styleConfig,
markWatchedKey,
subtitleSidebarToggleKey,
knownWordMaturityEnabled,
] = await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
readKnownWordMaturityEnabled(),
]);
helpSections = buildSessionHelpSections({
sessionBindings,
markWatchedKey,
subtitleSidebarToggleKey,
subtitleStyle: styleConfig ?? {},
knownWordMaturityEnabled,
});
applyFilterAndRender();
return true;
+8
View File
@@ -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',
+42
View File
@@ -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;
@@ -204,3 +204,51 @@ 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');
});
+67
View File
@@ -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<string, string> }).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<string, string> }).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();
}
});
+33
View File
@@ -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);
+1
View File
@@ -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',
+2
View File
@@ -82,6 +82,8 @@ export interface AnkiConnectConfig {
};
knownWords?: {
highlightEnabled?: boolean;
maturityEnabled?: boolean;
matureThresholdDays?: number;
refreshMinutes?: number;
addMinedWordsImmediately?: boolean;
matchMode?: NPlusOneMatchMode;
+2
View File
@@ -254,6 +254,8 @@ export interface ResolvedConfig {
};
knownWords: {
highlightEnabled: boolean;
maturityEnabled: boolean;
matureThresholdDays: number;
refreshMinutes: number;
addMinedWordsImmediately: boolean;
matchMode: NPlusOneMatchMode;
+1
View File
@@ -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'
+11
View File
@@ -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;