mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(overlay): Anki maturity-based known-word highlighting
Color known-word subtitle highlights by Anki card maturity (new, learning, young, mature) like asbplayer (#171). Notes are classified server-side with Anki search filters (prop:ivl, is:learn) during the known-word cache refresh, so no per-card data is fetched. A word's tier is its most mature matching card/note, with the same reading-aware matching as boolean known-word lookups. - known-word cache v4 state persists per-note tiers; lifecycle key only gains the maturity field while enabled so existing caches survive - ankiConnect.knownWords.maturityEnabled + matureThresholdDays (21) - subtitleStyle.knownWordMaturityColors with catppuccin defaults - word-maturity-<tier> class rides on word-known so hover/selection rules keep applying; falls back to knownWordColor without tier data - runtime toggle subtitle.annotation.knownWords.maturityEnabled
This commit is contained in:
@@ -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,303 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';
|
||||
|
||||
interface HarnessNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<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[]>;
|
||||
};
|
||||
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[]>(),
|
||||
};
|
||||
const deps = {
|
||||
client: {
|
||||
findNotes: async (query: string) => {
|
||||
calls.findNotes += 1;
|
||||
calls.queries.push(query);
|
||||
if (clientState.findNotesByQuery.has(query)) {
|
||||
return clientState.findNotesByQuery.get(query) ?? [];
|
||||
}
|
||||
return clientState.findNotesResult;
|
||||
},
|
||||
notesInfo: async (noteIds: number[]) => {
|
||||
calls.notesInfo += 1;
|
||||
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
|
||||
},
|
||||
},
|
||||
getConfig: () => config,
|
||||
knownWordCacheStatePath: statePath,
|
||||
showStatusNotification: () => undefined,
|
||||
};
|
||||
return {
|
||||
manager: new KnownWordCacheManager(deps),
|
||||
calls,
|
||||
statePath,
|
||||
clientState,
|
||||
createSiblingManager: () => new KnownWordCacheManager(deps),
|
||||
cleanup: () => {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
||||
return {
|
||||
deck: 'Mining',
|
||||
fields: { word: 'Word' },
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
maturityEnabled: true,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('lifecycle config key is unchanged when maturity is disabled', () => {
|
||||
const disabled: AnkiConnectConfig = {
|
||||
knownWords: { highlightEnabled: true, refreshMinutes: 60 },
|
||||
};
|
||||
// Upgrading users keep their persisted cache: the key must not gain fields
|
||||
// while maturity is off.
|
||||
assert.equal(
|
||||
getKnownWordCacheLifecycleConfig(disabled),
|
||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||
);
|
||||
|
||||
const enabled: AnkiConnectConfig = {
|
||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
||||
};
|
||||
assert.equal(
|
||||
getKnownWordCacheLifecycleConfig(enabled),
|
||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
||||
);
|
||||
|
||||
const customThreshold: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
maturityEnabled: true,
|
||||
matureThresholdDays: 30,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
getKnownWordCacheLifecycleConfig(customThreshold),
|
||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}',
|
||||
);
|
||||
});
|
||||
|
||||
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
|
||||
clientState.notesInfoResult = [
|
||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||
{ noteId: 3, fields: { Word: { value: '鳥' } } },
|
||||
{ noteId: 4, fields: { Word: { value: '魚' } } },
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(calls.findNotes, 4);
|
||||
assert.equal(manager.getKnownWordTier('猫'), 'mature');
|
||||
assert.equal(manager.getKnownWordTier('犬'), 'young');
|
||||
assert.equal(manager.getKnownWordTier('鳥'), 'learning');
|
||||
assert.equal(manager.getKnownWordTier('魚'), 'new');
|
||||
assert.equal(manager.getKnownWordTier('馬'), null);
|
||||
// Boolean matching still works alongside tiers.
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(manager.isKnownWord('魚'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a note with cards in several tiers counts as its most mature card', async () => {
|
||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.getKnownWordTier('猫'), 'mature');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('a word matched by several notes takes the most mature note tier', async () => {
|
||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||
clientState.notesInfoResult = [
|
||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.getKnownWordTier('猫'), 'young');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('tiers are reading-aware for words with several readings', async () => {
|
||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||
clientState.notesInfoResult = [
|
||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature');
|
||||
assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning');
|
||||
// No reading given: fail-open across readings, most mature wins.
|
||||
assert.equal(manager.getKnownWordTier('床'), 'mature');
|
||||
// Unknown reading for a reading-locked word: no match, no tier.
|
||||
assert.equal(manager.getKnownWordTier('床', 'しょう'), null);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('reading-only fallback resolves tiers unless opted out', async () => {
|
||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
|
||||
clientState.notesInfoResult = [
|
||||
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
|
||||
assert.equal(manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }), null);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => {
|
||||
const config = maturityConfig();
|
||||
config.knownWords = { ...config.knownWords, maturityEnabled: false };
|
||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(calls.findNotes, 1);
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(manager.getKnownWordTier('猫'), null);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('tiers persist to v4 state and reload without refetching', async () => {
|
||||
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
|
||||
createMaturityHarness(maturityConfig());
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||
clientState.notesInfoResult = [
|
||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
tiers: Record<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();
|
||||
}
|
||||
});
|
||||
@@ -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 }],
|
||||
});
|
||||
|
||||
@@ -4,7 +4,16 @@ import path from 'path';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
classifyKnownWordNoteTier,
|
||||
fetchKnownWordMaturityTierSets,
|
||||
getKnownWordMaturityEnabled,
|
||||
getMatureIntervalThresholdDays,
|
||||
maxKnownWordMaturityTier,
|
||||
sanitizeKnownWordMaturityTier,
|
||||
} from './known-word-maturity';
|
||||
import {
|
||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||
KnownWordEntry,
|
||||
@@ -62,11 +71,17 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
||||
}
|
||||
|
||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||
return JSON.stringify({
|
||||
const payload: Record<string, unknown> = {
|
||||
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
|
||||
scope: getKnownWordCacheScopeForConfig(config),
|
||||
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
||||
});
|
||||
};
|
||||
// The maturity field is only added while enabled so persisted caches from
|
||||
// before the feature existed (or with it off) keep their identity.
|
||||
if (getKnownWordMaturityEnabled(config)) {
|
||||
payload.maturity = getMatureIntervalThresholdDays(config);
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export interface KnownWordCacheNoteInfo {
|
||||
@@ -96,7 +111,19 @@ interface KnownWordCacheStateV3 {
|
||||
readonly notes: Record<string, KnownWordEntry[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||
interface KnownWordCacheStateV4 {
|
||||
readonly version: 4;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly notes: Record<string, KnownWordEntry[]>;
|
||||
readonly tiers: Record<string, KnownWordMaturityTier>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState =
|
||||
| KnownWordCacheStateV1
|
||||
| KnownWordCacheStateV2
|
||||
| KnownWordCacheStateV3
|
||||
| KnownWordCacheStateV4;
|
||||
|
||||
const NO_READING_KEY = '';
|
||||
|
||||
@@ -125,12 +152,13 @@ type KnownWordQueryScope = {
|
||||
export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||
// word → (hiragana reading | NO_READING_KEY → note ids). NO_READING_KEY
|
||||
// entries fail open: the word matches regardless of the token's reading.
|
||||
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||
private readingCounts = new Map<string, number>();
|
||||
private wordReadingNoteIds = new Map<string, Map<string, Set<number>>>();
|
||||
// hiragana reading → note ids, so kana tokens still match by reading alone.
|
||||
private readingNoteIds = new Map<string, Set<number>>();
|
||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||
private noteTierById = new Map<number, KnownWordMaturityTier>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
@@ -156,7 +184,7 @@ export class KnownWordCacheManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
||||
if (knownReadings && knownReadings.size > 0) {
|
||||
const normalizedReading =
|
||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||
@@ -168,7 +196,7 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
// Callers that look up a kanji token's reading (not subtitle text) must
|
||||
// opt out of the reading-only fallback: readingCounts holds readings of
|
||||
// opt out of the reading-only fallback: readingNoteIds holds readings of
|
||||
// every note including kanji words, so 渓谷's けいこく would match a
|
||||
// mined 警告/けいこく.
|
||||
if (options?.allowReadingOnlyMatch === false) {
|
||||
@@ -182,7 +210,73 @@ export class KnownWordCacheManager {
|
||||
if ([...hiragana].length === 1) {
|
||||
return false;
|
||||
}
|
||||
return this.readingCounts.has(hiragana);
|
||||
return this.readingNoteIds.has(hiragana);
|
||||
}
|
||||
|
||||
// Maturity tier for a matching known word, following the exact matching
|
||||
// rules of isKnownWord. A match with no tier data (tier fetch failed or
|
||||
// pre-v4 cache) returns null so rendering falls back to the single
|
||||
// known-word color.
|
||||
getKnownWordTier(
|
||||
text: string,
|
||||
reading?: string,
|
||||
options?: { allowReadingOnlyMatch?: boolean },
|
||||
): KnownWordMaturityTier | null {
|
||||
if (!getKnownWordMaturityEnabled(this.deps.getConfig())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(text);
|
||||
if (normalized.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
||||
if (knownReadings && knownReadings.size > 0) {
|
||||
const normalizedReading =
|
||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||
let tier: KnownWordMaturityTier | null = null;
|
||||
if (normalizedReading.length === 0) {
|
||||
for (const noteIds of knownReadings.values()) {
|
||||
tier = this.maxTierForNotes(tier, noteIds);
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
const noReadingNotes = knownReadings.get(NO_READING_KEY);
|
||||
if (noReadingNotes) {
|
||||
tier = this.maxTierForNotes(tier, noReadingNotes);
|
||||
}
|
||||
const exactReadingNotes = knownReadings.get(normalizedReading);
|
||||
if (exactReadingNotes) {
|
||||
tier = this.maxTierForNotes(tier, exactReadingNotes);
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
if (options?.allowReadingOnlyMatch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hiragana = convertKatakanaToHiragana(normalized);
|
||||
if ([...hiragana].length === 1) {
|
||||
return null;
|
||||
}
|
||||
const readingNotes = this.readingNoteIds.get(hiragana);
|
||||
return readingNotes ? this.maxTierForNotes(null, readingNotes) : null;
|
||||
}
|
||||
|
||||
private maxTierForNotes(
|
||||
current: KnownWordMaturityTier | null,
|
||||
noteIds: ReadonlySet<number>,
|
||||
): KnownWordMaturityTier | null {
|
||||
let tier = current;
|
||||
for (const noteId of noteIds) {
|
||||
tier = maxKnownWordMaturityTier(tier, this.noteTierById.get(noteId) ?? null);
|
||||
if (tier === 'mature') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
|
||||
refresh(force = false): Promise<void> {
|
||||
@@ -229,7 +323,7 @@ export class KnownWordCacheManager {
|
||||
let didMutateCache = false;
|
||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||
didMutateCache = this.wordReadingNoteIds.size > 0 || this.noteEntriesById.size > 0;
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsStateKey) {
|
||||
@@ -247,6 +341,11 @@ export class KnownWordCacheManager {
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
// A just-mined card has never been reviewed.
|
||||
if (this.isMaturityTrackingEnabled() && this.noteEntriesById.has(noteInfo.noteId)) {
|
||||
this.noteTierById.set(noteInfo.noteId, 'new');
|
||||
}
|
||||
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
}
|
||||
@@ -290,6 +389,13 @@ export class KnownWordCacheManager {
|
||||
this.isRefreshingKnownWords = true;
|
||||
try {
|
||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||
const tierSets = this.isMaturityTrackingEnabled()
|
||||
? await fetchKnownWordMaturityTierSets(
|
||||
(query, options) => this.deps.client.findNotes(query, options),
|
||||
this.getKnownWordQueryScopes().map((scope) => scope.query),
|
||||
getMatureIntervalThresholdDays(this.deps.getConfig()),
|
||||
)
|
||||
: null;
|
||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||
|
||||
if (this.noteEntriesById.size === 0) {
|
||||
@@ -316,13 +422,21 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
this.noteTierById = new Map();
|
||||
if (tierSets) {
|
||||
for (const noteId of currentNoteIds) {
|
||||
this.noteTierById.set(noteId, classifyKnownWordNoteTier(noteId, tierSets));
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
this.knownWordsStateKey = frozenStateKey;
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.wordReadingCounts.size}`,
|
||||
`wordCount=${this.wordReadingNoteIds.size}`,
|
||||
tierSets ? `maturityTiers=${this.noteTierById.size}` : 'maturityTiers=off',
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -337,6 +451,10 @@ export class KnownWordCacheManager {
|
||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||
}
|
||||
|
||||
private isMaturityTrackingEnabled(): boolean {
|
||||
return getKnownWordMaturityEnabled(this.deps.getConfig());
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||
}
|
||||
@@ -593,12 +711,13 @@ export class KnownWordCacheManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
this.removeEntriesFromIndexes(noteId, previousEntries);
|
||||
if (normalizedEntries.length > 0) {
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||
} else {
|
||||
this.noteEntriesById.delete(noteId);
|
||||
this.noteTierById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -609,54 +728,68 @@ export class KnownWordCacheManager {
|
||||
return;
|
||||
}
|
||||
this.noteEntriesById.delete(noteId);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
this.noteTierById.delete(noteId);
|
||||
this.removeEntriesFromIndexes(noteId, previousEntries);
|
||||
}
|
||||
|
||||
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||
private addEntriesToIndexes(noteId: number, entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
let readings = this.wordReadingCounts.get(entry.word);
|
||||
let readings = this.wordReadingNoteIds.get(entry.word);
|
||||
if (!readings) {
|
||||
readings = new Map();
|
||||
this.wordReadingCounts.set(entry.word, readings);
|
||||
this.wordReadingNoteIds.set(entry.word, readings);
|
||||
}
|
||||
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||
let noteIds = readings.get(readingKey);
|
||||
if (!noteIds) {
|
||||
noteIds = new Set();
|
||||
readings.set(readingKey, noteIds);
|
||||
}
|
||||
noteIds.add(noteId);
|
||||
if (entry.reading) {
|
||||
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||
let readingNotes = this.readingNoteIds.get(entry.reading);
|
||||
if (!readingNotes) {
|
||||
readingNotes = new Set();
|
||||
this.readingNoteIds.set(entry.reading, readingNotes);
|
||||
}
|
||||
readingNotes.add(noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||
private removeEntriesFromIndexes(noteId: number, entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
const readings = this.wordReadingCounts.get(entry.word);
|
||||
const readings = this.wordReadingNoteIds.get(entry.word);
|
||||
if (readings) {
|
||||
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
readings.set(readingKey, nextCount);
|
||||
} else {
|
||||
readings.delete(readingKey);
|
||||
if (readings.size === 0) {
|
||||
this.wordReadingCounts.delete(entry.word);
|
||||
const noteIds = readings.get(readingKey);
|
||||
if (noteIds) {
|
||||
noteIds.delete(noteId);
|
||||
if (noteIds.size === 0) {
|
||||
readings.delete(readingKey);
|
||||
if (readings.size === 0) {
|
||||
this.wordReadingNoteIds.delete(entry.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.reading) {
|
||||
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||
if (nextReadingCount > 0) {
|
||||
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||
} else {
|
||||
this.readingCounts.delete(entry.reading);
|
||||
const readingNotes = this.readingNoteIds.get(entry.reading);
|
||||
if (readingNotes) {
|
||||
readingNotes.delete(noteId);
|
||||
if (readingNotes.size === 0) {
|
||||
this.readingNoteIds.delete(entry.reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.wordReadingCounts = new Map();
|
||||
this.readingCounts = new Map();
|
||||
this.wordReadingNoteIds = new Map();
|
||||
this.readingNoteIds = new Map();
|
||||
this.noteEntriesById = new Map();
|
||||
this.noteTierById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
@@ -689,7 +822,7 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 3) {
|
||||
if (parsed.version === 3 || parsed.version === 4) {
|
||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
@@ -700,7 +833,16 @@ export class KnownWordCacheManager {
|
||||
continue;
|
||||
}
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||
}
|
||||
if (parsed.version === 4) {
|
||||
for (const [noteIdKey, tier] of Object.entries(parsed.tiers)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
const sanitizedTier = sanitizeKnownWordMaturityTier(tier);
|
||||
if (sanitizedTier && this.noteEntriesById.has(noteId)) {
|
||||
this.noteTierById.set(noteId, sanitizedTier);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
@@ -723,7 +865,7 @@ export class KnownWordCacheManager {
|
||||
continue;
|
||||
}
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||
}
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
@@ -741,17 +883,23 @@ export class KnownWordCacheManager {
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const notes: Record<string, KnownWordEntry[]> = {};
|
||||
const tiers: Record<string, KnownWordMaturityTier> = {};
|
||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||
if (entries.length > 0) {
|
||||
notes[String(noteId)] = entries;
|
||||
const tier = this.noteTierById.get(noteId);
|
||||
if (tier) {
|
||||
tiers[String(noteId)] = tier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV3 = {
|
||||
version: 3,
|
||||
const state: KnownWordCacheStateV4 = {
|
||||
version: 4,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
notes,
|
||||
tiers,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
} catch (error) {
|
||||
@@ -762,18 +910,33 @@ export class KnownWordCacheManager {
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||
if (
|
||||
candidate.version !== 1 &&
|
||||
candidate.version !== 2 &&
|
||||
candidate.version !== 3 &&
|
||||
candidate.version !== 4
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (candidate.version !== 3) {
|
||||
if (candidate.version === 1 || candidate.version === 2) {
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (candidate.version === 2 || candidate.version === 3) {
|
||||
if (candidate.version === 4) {
|
||||
// Per-tier values are sanitized entry-by-entry at load time.
|
||||
if (
|
||||
typeof candidate.tiers !== 'object' ||
|
||||
candidate.tiers === null ||
|
||||
Array.isArray(candidate.tiers)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import {
|
||||
DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS,
|
||||
buildKnownWordMaturityTierQueries,
|
||||
classifyKnownWordNoteTier,
|
||||
getKnownWordMaturityEnabled,
|
||||
getMatureIntervalThresholdDays,
|
||||
maxKnownWordMaturityTier,
|
||||
sanitizeKnownWordMaturityTier,
|
||||
} from './known-word-maturity';
|
||||
|
||||
function makeConfig(knownWords: AnkiConnectConfig['knownWords']): AnkiConnectConfig {
|
||||
return { url: 'http://127.0.0.1:8765', knownWords } as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
test('maturity is enabled only when both highlight and maturity flags are on', () => {
|
||||
assert.equal(
|
||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: true })),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: false, maturityEnabled: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: false })),
|
||||
false,
|
||||
);
|
||||
assert.equal(getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true })), false);
|
||||
assert.equal(getKnownWordMaturityEnabled(makeConfig(undefined)), false);
|
||||
});
|
||||
|
||||
test('mature threshold falls back to default for invalid values', () => {
|
||||
assert.equal(DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, 21);
|
||||
assert.equal(
|
||||
getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 30 })),
|
||||
30,
|
||||
);
|
||||
assert.equal(
|
||||
getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 14.9 })),
|
||||
14,
|
||||
);
|
||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 0 })), 21);
|
||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: -5 })), 21);
|
||||
assert.equal(
|
||||
getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: Number.NaN })),
|
||||
21,
|
||||
);
|
||||
assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21);
|
||||
assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21);
|
||||
});
|
||||
|
||||
test('tier queries append Anki search props to a deck scope query', () => {
|
||||
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
||||
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21');
|
||||
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21');
|
||||
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
||||
});
|
||||
|
||||
test('tier queries with an empty scope query have no leading space', () => {
|
||||
const queries = buildKnownWordMaturityTierQueries('', 30);
|
||||
assert.equal(queries.mature, 'prop:ivl>=30');
|
||||
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30');
|
||||
assert.equal(queries.learning, 'is:learn');
|
||||
});
|
||||
|
||||
test('note classification picks the most mature matching tier', () => {
|
||||
const sets = {
|
||||
mature: new Set([1, 4]),
|
||||
young: new Set([2, 4]),
|
||||
learning: new Set([3, 4, 2]),
|
||||
};
|
||||
assert.equal(classifyKnownWordNoteTier(1, sets), 'mature');
|
||||
assert.equal(classifyKnownWordNoteTier(2, sets), 'young');
|
||||
assert.equal(classifyKnownWordNoteTier(3, sets), 'learning');
|
||||
// Note with mature, young, and learning cards: most mature card wins.
|
||||
assert.equal(classifyKnownWordNoteTier(4, sets), 'mature');
|
||||
assert.equal(classifyKnownWordNoteTier(99, sets), 'new');
|
||||
});
|
||||
|
||||
test('maxKnownWordMaturityTier picks the higher tier and tolerates null', () => {
|
||||
assert.equal(maxKnownWordMaturityTier('mature', 'new'), 'mature');
|
||||
assert.equal(maxKnownWordMaturityTier('learning', 'young'), 'young');
|
||||
assert.equal(maxKnownWordMaturityTier('new', null), 'new');
|
||||
assert.equal(maxKnownWordMaturityTier(null, 'learning'), 'learning');
|
||||
assert.equal(maxKnownWordMaturityTier(null, null), null);
|
||||
assert.equal(maxKnownWordMaturityTier(undefined, undefined), null);
|
||||
});
|
||||
|
||||
test('sanitizeKnownWordMaturityTier accepts only valid tiers', () => {
|
||||
assert.equal(sanitizeKnownWordMaturityTier('mature'), 'mature');
|
||||
assert.equal(sanitizeKnownWordMaturityTier('young'), 'young');
|
||||
assert.equal(sanitizeKnownWordMaturityTier('learning'), 'learning');
|
||||
assert.equal(sanitizeKnownWordMaturityTier('new'), 'new');
|
||||
assert.equal(sanitizeKnownWordMaturityTier('MATURE'), null);
|
||||
assert.equal(sanitizeKnownWordMaturityTier(''), null);
|
||||
assert.equal(sanitizeKnownWordMaturityTier(21), null);
|
||||
assert.equal(sanitizeKnownWordMaturityTier(null), null);
|
||||
assert.equal(sanitizeKnownWordMaturityTier(undefined), null);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
||||
|
||||
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
|
||||
|
||||
// Ascending maturity; index order backs tier comparison.
|
||||
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
||||
|
||||
export interface KnownWordMaturityTierQueries {
|
||||
mature: string;
|
||||
young: string;
|
||||
learning: string;
|
||||
}
|
||||
|
||||
export interface KnownWordMaturityTierSets {
|
||||
mature: ReadonlySet<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.
|
||||
export function buildKnownWordMaturityTierQueries(
|
||||
scopeQuery: string,
|
||||
thresholdDays: number,
|
||||
): KnownWordMaturityTierQueries {
|
||||
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
|
||||
return {
|
||||
mature: `${prefix}prop:ivl>=${thresholdDays}`,
|
||||
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`,
|
||||
learning: `${prefix}is:learn`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchKnownWordMaturityTierSets(
|
||||
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -353,6 +353,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||
path.startsWith('subtitleStyle.knownWordMaturityColors.') ||
|
||||
path === 'subtitleStyle.enableJlpt' ||
|
||||
path === 'subtitleStyle.knownWordColor' ||
|
||||
path === 'subtitleStyle.nPlusOneColor' ||
|
||||
|
||||
@@ -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,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { KnownWordMaturityTier, MergedToken, PartOfSpeech } from '../../../types';
|
||||
import { annotateTokens, AnnotationStageDeps } from './annotation-stage';
|
||||
|
||||
function makeToken(overrides: Partial<MergedToken> = {}): 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;
|
||||
}
|
||||
@@ -616,6 +627,28 @@ function computeTokenKnownStatus(
|
||||
);
|
||||
}
|
||||
|
||||
// Maturity tier for a token already confirmed known, following the same
|
||||
// primary + kana-fallback lookup sequence as computeTokenKnownStatus so the
|
||||
// tier always describes a note the boolean match could have come from.
|
||||
function computeTokenKnownMaturity(
|
||||
token: MergedToken,
|
||||
getKnownWordTier: NonNullable<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 +710,11 @@ export function annotateTokens(
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const knownMaturity =
|
||||
knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier
|
||||
? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode)
|
||||
: undefined;
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
|
||||
// A confirmed character-name match must survive the POS noise filter:
|
||||
@@ -696,6 +734,7 @@ export function annotateTokens(
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
knownMaturity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -712,6 +751,7 @@ export function annotateTokens(
|
||||
return {
|
||||
...token,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
knownMaturity,
|
||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||
frequencyRank,
|
||||
jlptLevel,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,54 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
|
||||
|
||||
assert.equal(actual, 'word');
|
||||
});
|
||||
|
||||
test('computeWordClass adds the maturity tier class alongside word-known', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
knownMaturity: 'mature',
|
||||
surface: '猫',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(token), 'word word-known word-maturity-mature');
|
||||
});
|
||||
|
||||
test('computeWordClass keeps the plain known class when no maturity tier is set', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
surface: '猫',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(token), 'word word-known');
|
||||
});
|
||||
|
||||
test('computeWordClass composes maturity with JLPT classes', () => {
|
||||
const token = createToken({
|
||||
isKnown: true,
|
||||
knownMaturity: 'young',
|
||||
jlptLevel: 'N3',
|
||||
surface: '猫',
|
||||
});
|
||||
|
||||
assert.equal(computeWordClass(token), 'word word-known word-maturity-young word-jlpt-n3');
|
||||
});
|
||||
|
||||
test('computeWordClass gives n+1 and name matches precedence over maturity', () => {
|
||||
const nPlusOne = createToken({
|
||||
isKnown: true,
|
||||
knownMaturity: 'mature',
|
||||
isNPlusOneTarget: true,
|
||||
surface: '犬',
|
||||
});
|
||||
assert.equal(computeWordClass(nPlusOne), 'word word-n-plus-one');
|
||||
|
||||
const nameMatch = createToken({
|
||||
isKnown: true,
|
||||
knownMaturity: 'mature',
|
||||
surface: 'アクア',
|
||||
}) as MergedToken & { isNameMatch?: boolean };
|
||||
nameMatch.isNameMatch = true;
|
||||
assert.equal(
|
||||
computeWordClass(nameMatch, { nameMatchEnabled: true }),
|
||||
'word word-name-match',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface AnkiConnectConfig {
|
||||
};
|
||||
knownWords?: {
|
||||
highlightEnabled?: boolean;
|
||||
maturityEnabled?: boolean;
|
||||
matureThresholdDays?: number;
|
||||
refreshMinutes?: number;
|
||||
addMinedWordsImmediately?: boolean;
|
||||
matchMode?: NPlusOneMatchMode;
|
||||
|
||||
@@ -254,6 +254,8 @@ export interface ResolvedConfig {
|
||||
};
|
||||
knownWords: {
|
||||
highlightEnabled: boolean;
|
||||
maturityEnabled: boolean;
|
||||
matureThresholdDays: number;
|
||||
refreshMinutes: number;
|
||||
addMinedWordsImmediately: boolean;
|
||||
matchMode: NPlusOneMatchMode;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user