feat(scripts): add a known-word highlight verifier

Adds verify-known-word-highlights:electron, which tokenizes a subtitle file
through the real Yomitan/MeCab pipeline against the live known-word cache
and prints each cue in the configured tier colors, so highlighting can be
checked without stepping through playback.

--audit re-derives every rendered tier from live Anki card data (notesInfo
plus cardsInfo intervals) and reports tokens whose color disagrees, which
catches stale cache entries and tier-classification bugs alike. --profile-copy
runs against a scratch Yomitan profile so the check works while SubMiner
holds the userData lock.

Exposes KnownWordCacheManager.getKnownWordMatchNoteIds and the annotation
stage's known-word text/reading resolvers so the audit can trace a rendered
tier back to the exact notes behind it.
This commit is contained in:
2026-07-26 01:33:04 -07:00
parent 75566a49bf
commit 105bd86410
8 changed files with 830 additions and 19 deletions
@@ -0,0 +1,7 @@
type: internal
area: tokenizer
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
+10
View File
@@ -71,6 +71,16 @@ How often the `learning` color appears depends on your deck preset: with no rele
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
**Checking the colors you actually see:**
Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file:
```sh
bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit
```
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
+1
View File
@@ -13,6 +13,7 @@
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
+587
View File
@@ -0,0 +1,587 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
KnownWordCacheManager,
getKnownWordCacheLifecycleConfig,
} from '../src/anki-integration/known-word-cache.js';
import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { ConfigService } from '../src/config/service.js';
import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js';
import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js';
import {
resolveCompleteTokenReading,
resolveKnownWordReadingForMatch,
resolveKnownWordText,
} from '../src/core/services/tokenizer/annotation-stage.js';
import { MecabTokenizer } from '../src/mecab-tokenizer.js';
import type { MergedToken } from '../src/types.js';
import type { KnownWordMaturityTier } from '../src/types/subtitle.js';
import {
createYomitanRuntimeStateWithSearch,
destroyParserWindow,
loadElectronModule,
withTimeout,
type YomitanRuntimeState,
} from './yomitan-script-runtime.js';
interface CliOptions {
input: string;
configDir?: string;
yomitanUserDataPath?: string;
yomitanExtensionPath?: string;
limit: number;
audit: boolean;
refresh: boolean;
json: boolean;
quiet: boolean;
profileCopy: boolean;
}
type TierOrFallback = KnownWordMaturityTier | 'known-no-tier';
interface TokenReport {
cueIndex: number;
startTime: number;
surface: string;
headword: string;
reading: string;
tier: TierOrFallback;
noteIds: number[];
}
interface AuditMismatch extends TokenReport {
liveTier: KnownWordMaturityTier | 'no-notes';
intervals: number[];
}
const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
const FALLBACK_TIER_COLORS: Record<KnownWordMaturityTier, string> = {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
};
function parseCliArgs(argv: string[]): CliOptions {
const options: CliOptions = {
input: '',
limit: 0,
audit: false,
refresh: false,
json: false,
quiet: false,
profileCopy: false,
};
const rest: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const takeValue = (flag: string): string => {
const next = argv[i + 1];
if (!next) {
throw new Error(`Missing value for ${flag}`);
}
i += 1;
return next;
};
if (arg === '--help' || arg === '-h') {
process.stdout.write(`${usage()}\n`);
process.exit(0);
} else if (arg === '--input') {
options.input = takeValue(arg);
} else if (arg === '--config-dir') {
options.configDir = takeValue(arg);
} else if (arg === '--yomitan-user-data') {
options.yomitanUserDataPath = takeValue(arg);
} else if (arg === '--yomitan-extension-path') {
options.yomitanExtensionPath = takeValue(arg);
} else if (arg === '--limit') {
options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0);
} else if (arg === '--audit') {
options.audit = true;
} else if (arg === '--refresh') {
options.refresh = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--quiet') {
options.quiet = true;
} else if (arg === '--profile-copy') {
options.profileCopy = true;
} else if (arg === '--') {
// `bun run <script> -- --flag ...` forwards the separator too.
continue;
} else if (arg.startsWith('--')) {
throw new Error(`Unknown flag: ${arg}`);
} else {
rest.push(arg);
}
}
if (!options.input && rest.length > 0) {
options.input = rest.join(' ');
}
if (!options.input) {
throw new Error(`No subtitle file given.\n${usage()}`);
}
return options;
}
function usage(): string {
return [
'Usage: verify-known-word-highlights <subtitle.srt|.ass> [flags]',
'',
' --limit <n> Only check the first n cues (default: all)',
' --audit Re-derive every highlighted tier from live Anki card data',
' --refresh Force a known-word cache refresh before checking',
' --json Emit a machine-readable report',
' --quiet Skip the per-line colored dump',
' --profile-copy Copy the Yomitan profile to a scratch dir so this can run',
' while SubMiner is open (Electron locks the userData dir)',
' --config-dir <dir> SubMiner config dir (default: auto-detected)',
' --yomitan-user-data <dir> Electron userData dir holding the Yomitan profile',
' --yomitan-extension-path <dir>',
].join('\n');
}
const ANSI_RESET = '\u001b[0m';
function colorize(text: string, hex: string): string {
const normalized = hex.trim().replace(/^#/, '');
const expanded =
normalized.length === 3
? normalized
.split('')
.map((char) => `${char}${char}`)
.join('')
: normalized;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
return text;
}
const r = Number.parseInt(expanded.slice(0, 2), 16);
const g = Number.parseInt(expanded.slice(2, 4), 16);
const b = Number.parseInt(expanded.slice(4, 6), 16);
return `\u001b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
}
function formatTimestamp(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const mm = String(Math.floor(total / 60)).padStart(2, '0');
const ss = String(total % 60).padStart(2, '0');
return `${mm}:${ss}`;
}
// The user's real config dir is copied into a scratch dir so this read-only
// check can never rewrite config.jsonc (ConfigService migrates on load) or the
// live known-word cache (a --refresh persists tier data).
function createScratchState(configDir: string): { dir: string; cachePath: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-verify-'));
for (const fileName of ['config.jsonc', 'config.json']) {
const source = path.join(configDir, fileName);
if (fs.existsSync(source)) {
fs.copyFileSync(source, path.join(dir, fileName));
}
}
const cachePath = path.join(dir, 'known-words-cache.json');
const liveCachePath = path.join(configDir, 'known-words-cache.json');
if (fs.existsSync(liveCachePath)) {
fs.copyFileSync(liveCachePath, cachePath);
}
return { dir, cachePath };
}
// Electron locks a userData dir, so the Yomitan profile can't be shared with a
// running SubMiner. Copying the dictionary-bearing parts of the profile lets
// this check run mid-session (IndexedDB alone is often over 1 GB).
const YOMITAN_PROFILE_DIRS = [
'extensions',
'IndexedDB',
'Local Extension Settings',
'Local Storage',
];
function copyYomitanProfile(sourceUserDataPath: string): string {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-profile-'));
for (const name of YOMITAN_PROFILE_DIRS) {
const source = path.join(sourceUserDataPath, name);
if (fs.existsSync(source)) {
fs.cpSync(source, path.join(target, name), { recursive: true });
}
}
return target;
}
function readPersistedCacheScope(cachePath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { scope?: unknown };
return typeof parsed.scope === 'string' ? parsed.scope : null;
} catch {
return null;
}
}
function createAnkiClient(url: string) {
const request = async (action: string, params: unknown): Promise<unknown> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, version: 6, params }),
});
const payload = (await response.json()) as { result: unknown; error: string | null };
if (payload.error) {
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
}
return payload.result;
};
return {
request,
findNotes: (query: string) => request('findNotes', { query }),
notesInfo: (noteIds: number[]) => request('notesInfo', { notes: noteIds }),
};
}
function resolveTokenMatch(
token: MergedToken,
cache: KnownWordCacheManager,
matchMode: 'surface' | 'headword',
): { tier: KnownWordMaturityTier | null; noteIds: Set<number> } {
const matchText = resolveKnownWordText(token.surface, token.headword, matchMode);
const matchReading = resolveKnownWordReadingForMatch(token, matchMode);
const primaryTier = matchText ? cache.getKnownWordTier(matchText, matchReading) : null;
if (primaryTier) {
return { tier: primaryTier, noteIds: cache.getKnownWordMatchNoteIds(matchText, matchReading) };
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading || fallbackReading === matchText.trim()) {
return {
tier: null,
noteIds: matchText ? cache.getKnownWordMatchNoteIds(matchText, matchReading) : new Set(),
};
}
const fallbackOptions = { allowReadingOnlyMatch: false } as const;
return {
tier: cache.getKnownWordTier(fallbackReading, undefined, fallbackOptions),
noteIds: cache.getKnownWordMatchNoteIds(fallbackReading, undefined, fallbackOptions),
};
}
// Ground truth straight from card data, independent of the Anki search filters
// the cache refresh uses (prop:ivl / is:learn).
function classifyCardsIntoTier(
cards: Array<{ interval: number; queue: number; type: number }>,
thresholdDays: number,
): KnownWordMaturityTier {
if (cards.some((card) => card.interval >= thresholdDays)) return 'mature';
if (cards.some((card) => card.interval >= 1)) return 'young';
if (
cards.some((card) => card.type === 1 || card.type === 3 || card.queue === 1 || card.queue === 4)
)
return 'learning';
return 'new';
}
async function auditTokens(
reports: TokenReport[],
client: ReturnType<typeof createAnkiClient>,
thresholdDays: number,
): Promise<{ mismatches: AuditMismatch[]; auditedNotes: number }> {
const noteIds = [...new Set(reports.flatMap((report) => report.noteIds))];
const cardIdsByNote = new Map<number, number[]>();
for (let i = 0; i < noteIds.length; i += 500) {
const infos = (await client.notesInfo(noteIds.slice(i, i + 500))) as Array<{
noteId: number;
cards?: number[];
}>;
for (const info of infos) {
cardIdsByNote.set(info.noteId, info.cards ?? []);
}
}
const allCardIds = [...cardIdsByNote.values()].flat();
const cardById = new Map<number, { interval: number; queue: number; type: number }>();
for (let i = 0; i < allCardIds.length; i += 500) {
const infos = (await client.request('cardsInfo', {
cards: allCardIds.slice(i, i + 500),
})) as Array<{ cardId: number; interval: number; queue: number; type: number }>;
for (const info of infos) {
cardById.set(info.cardId, {
interval: info.interval,
queue: info.queue,
type: info.type,
});
}
}
const mismatches: AuditMismatch[] = [];
for (const report of reports) {
const cards = report.noteIds
.flatMap((noteId) => cardIdsByNote.get(noteId) ?? [])
.map((cardId) => cardById.get(cardId))
.filter((card): card is { interval: number; queue: number; type: number } => Boolean(card));
const liveTier = cards.length === 0 ? 'no-notes' : classifyCardsIntoTier(cards, thresholdDays);
if (liveTier !== report.tier) {
mismatches.push({
...report,
liveTier,
intervals: cards.map((card) => card.interval),
});
}
}
return { mismatches, auditedNotes: noteIds.length };
}
async function main(): Promise<void> {
const args = parseCliArgs(process.argv.slice(2));
let electronModule: typeof import('electron') | null = null;
let yomitanState: YomitanRuntimeState | null = null;
let scratchDir: string | null = null;
let profileCopyDir: string | null = null;
try {
const configDir =
args.configDir ??
resolveConfigDir({
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
existsSync: fs.existsSync,
});
const scratch = createScratchState(configDir);
scratchDir = scratch.dir;
const config = new ConfigService(scratch.dir).getConfig();
const ankiConfig = config.ankiConnect;
const matchMode = ankiConfig.knownWords?.matchMode === 'surface' ? 'surface' : 'headword';
const thresholdDays = getMatureIntervalThresholdDays(ankiConfig);
const client = createAnkiClient(ankiConfig.url);
const cacheScopeKey = getKnownWordCacheLifecycleConfig(ankiConfig);
const cache = new KnownWordCacheManager({
client: { findNotes: (query) => client.findNotes(query), notesInfo: client.notesInfo },
getConfig: () => ankiConfig,
knownWordCacheStatePath: scratch.cachePath,
showStatusNotification: () => {},
});
// A cache whose persisted scope key no longer matches the config is
// discarded on load, so every token would come back unknown.
if (!args.refresh && readPersistedCacheScope(scratch.cachePath) !== cacheScopeKey) {
process.stderr.write(
'warning: the persisted known-word cache was built under different settings and will be ' +
'ignored (the app refetches it on its next refresh). Re-run with --refresh to fetch tiers now.\n',
);
}
// startLifecycle loads the persisted cache; the refresh timer it arms is
// cleared before the event loop can run it.
cache.startLifecycle();
cache.stopLifecycle();
if (args.refresh) {
await cache.refresh(true);
}
const cues = parseSubtitleCues(fs.readFileSync(args.input, 'utf-8'), args.input);
const selectedCues = args.limit > 0 ? cues.slice(0, args.limit) : cues;
const mecabTokenizer = new MecabTokenizer();
if (!(await mecabTokenizer.checkAvailability())) {
throw new Error('MeCab is not available; tokenization would not match the overlay.');
}
electronModule = await loadElectronModule();
const userDataPath = args.profileCopy
? copyYomitanProfile(args.yomitanUserDataPath ?? configDir)
: (args.yomitanUserDataPath ?? configDir);
profileCopyDir = args.profileCopy ? userDataPath : null;
if (electronModule?.app && typeof electronModule.app.setPath === 'function') {
electronModule.app.setPath('userData', userDataPath);
}
yomitanState = await createYomitanRuntimeStateWithSearch(
userDataPath,
args.yomitanExtensionPath,
);
if (!yomitanState.available) {
throw new Error(`Yomitan tokenizer unavailable: ${yomitanState.note ?? 'unknown reason'}`);
}
const deps = createTokenizerDepsRuntime({
getYomitanExt: () => yomitanState!.yomitanExt as never,
getYomitanSession: () => yomitanState!.yomitanSession as never,
getYomitanParserWindow: () => yomitanState!.parserWindow as never,
setYomitanParserWindow: (window) => {
yomitanState!.parserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanState!.parserReadyPromise as never,
setYomitanParserReadyPromise: (promise) => {
yomitanState!.parserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanState!.parserInitPromise as never,
setYomitanParserInitPromise: (promise) => {
yomitanState!.parserInitPromise = promise;
},
isKnownWord: (text, reading, options) => cache.isKnownWord(text, reading, options),
getKnownWordTier: (text, reading, options) => cache.getKnownWordTier(text, reading, options),
getKnownWordMatchMode: () => matchMode,
getKnownWordsEnabled: () => true,
// Other annotation layers are off so every colored token below is a
// known-word decision, not an N+1/frequency/name override.
getNPlusOneEnabled: () => false,
getNameMatchEnabled: () => false,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getJlptLevel: () => null,
getMecabTokenizer: () => ({ tokenize: (text: string) => mecabTokenizer.tokenize(text) }),
});
const styleColors = {
...FALLBACK_TIER_COLORS,
...(config.subtitleStyle?.knownWordMaturityColors ?? {}),
} as Record<KnownWordMaturityTier, string>;
const knownWordColor = config.subtitleStyle?.knownWordColor ?? '#a6da95';
const reports: TokenReport[] = [];
const tierCounts: Record<string, number> = {};
let knownTokens = 0;
let totalTokens = 0;
const lines: string[] = [];
for (const [cueIndex, cue] of selectedCues.entries()) {
const { text, tokens } = await withTimeout(
tokenizeSubtitle(cue.text, deps),
20_000,
`Tokenizer (cue ${cueIndex + 1})`,
);
if (!tokens || tokens.length === 0) {
continue;
}
let cursor = 0;
let rendered = '';
const ordered = [...tokens].sort((a, b) => (a.startPos ?? 0) - (b.startPos ?? 0));
for (const token of ordered) {
totalTokens += 1;
const start = Math.min(Math.max(0, token.startPos ?? 0), text.length);
const end = Math.min(Math.max(start, token.endPos ?? start), text.length);
if (start > cursor) {
rendered += text.slice(cursor, start);
}
const surfaceText = text.slice(start, end);
cursor = end;
if (!token.isKnown) {
rendered += surfaceText;
continue;
}
knownTokens += 1;
const tier: TierOrFallback = token.knownMaturity ?? 'known-no-tier';
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1;
rendered += colorize(
surfaceText,
tier === 'known-no-tier' ? knownWordColor : styleColors[tier],
);
reports.push({
cueIndex,
startTime: cue.startTime,
surface: token.surface,
headword: token.headword,
reading: token.reading,
tier,
noteIds: [...resolveTokenMatch(token, cache, matchMode).noteIds],
});
}
rendered += text.slice(cursor);
lines.push(`${formatTimestamp(cue.startTime)} ${rendered}`);
}
if (totalTokens === 0 && selectedCues.length > 0) {
throw new Error(
'Yomitan returned no tokens. SubMiner is probably running and holding the Electron ' +
'profile lock - quit it, or re-run with --profile-copy.',
);
}
let audit: { mismatches: AuditMismatch[]; auditedNotes: number } | null = null;
if (args.audit) {
audit = await auditTokens(reports, client, thresholdDays);
}
if (args.json) {
process.stdout.write(
`${JSON.stringify(
{
input: args.input,
cues: selectedCues.length,
totalTokens,
knownTokens,
tierCounts,
matureThresholdDays: thresholdDays,
matchMode,
tokens: reports,
audit,
},
null,
2,
)}\n`,
);
return;
}
if (!args.quiet) {
process.stdout.write(`${lines.join('\n')}\n\n`);
}
process.stdout.write(
[
`file : ${args.input}`,
`cues checked : ${selectedCues.length} of ${cues.length}`,
`tokens : ${totalTokens} (${knownTokens} known, ${totalTokens - knownTokens} unknown)`,
`match mode : ${matchMode} mature threshold: ${thresholdDays}d`,
'',
'known-token tiers:',
...TIERS.map(
(tier) =>
` ${colorize(tier.padEnd(9), styleColors[tier])} ${String(tierCounts[tier] ?? 0).padStart(5)}` +
` ${styleColors[tier]}`,
),
` ${colorize('no tier'.padEnd(9), knownWordColor)} ${String(tierCounts['known-no-tier'] ?? 0).padStart(5)} ${knownWordColor} (falls back to knownWordColor)`,
'',
].join('\n'),
);
if (audit) {
process.stdout.write(
`audit: ${reports.length - audit.mismatches.length}/${reports.length} highlighted tokens agree with live Anki card data ` +
`(${audit.auditedNotes} notes)\n`,
);
for (const mismatch of audit.mismatches.slice(0, 40)) {
process.stdout.write(
` ${formatTimestamp(mismatch.startTime)} ${mismatch.surface} (${mismatch.headword}) ` +
`shown=${mismatch.tier} live=${mismatch.liveTier} ivl=[${mismatch.intervals.join(', ')}] ` +
`notes=[${mismatch.noteIds.join(', ')}]\n`,
);
}
if (audit.mismatches.length > 40) {
process.stdout.write(` ... ${audit.mismatches.length - 40} more\n`);
}
}
} finally {
destroyParserWindow(yomitanState?.parserWindow ?? null);
for (const dir of [scratchDir, profileCopyDir]) {
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
if (electronModule?.app) {
electronModule.app.quit();
}
}
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(`Error: ${(error as Error).message}`);
process.exit(1);
});
+156
View File
@@ -0,0 +1,156 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveYomitanExtensionPath as resolveBuiltYomitanExtensionPath } from '../src/core/services/yomitan-extension-paths.js';
// Yomitan bootstrap for CLI scripts that need the app's real tokenizer. Mirrors
// what scripts/get_frequency.ts does inline; new scripts should import this.
export interface YomitanRuntimeState {
yomitanExt: unknown | null;
yomitanSession: unknown | null;
parserWindow: unknown | null;
parserReadyPromise: Promise<void> | null;
parserInitPromise: Promise<boolean> | null;
available: boolean;
note?: string;
}
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
export function destroyParserWindow(window: unknown): void {
if (!window || typeof window !== 'object') {
return;
}
const candidate = window as { isDestroyed?: () => boolean; destroy?: () => void };
if (typeof candidate.isDestroyed !== 'function' || typeof candidate.destroy !== 'function') {
return;
}
if (!candidate.isDestroyed()) {
candidate.destroy();
}
}
export async function loadElectronModule(): Promise<typeof import('electron') | null> {
try {
const electronImport = await import('electron');
return (electronImport.default ?? electronImport) as typeof import('electron');
} catch {
return null;
}
}
async function createYomitanRuntimeState(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const state: YomitanRuntimeState = {
yomitanExt: null,
yomitanSession: null,
parserWindow: null,
parserReadyPromise: null,
parserInitPromise: null,
available: false,
};
const electronImport = await loadElectronModule();
if (
!electronImport ||
!electronImport.app ||
typeof electronImport.app.whenReady !== 'function' ||
!electronImport.session
) {
state.note = electronImport
? 'electron runtime not available in this process'
: 'electron import failed';
return state;
}
try {
await electronImport.app.whenReady();
const loadYomitanExtension = (await import('../src/core/services/yomitan-extension-loader.js'))
.loadYomitanExtension as (options: {
userDataPath: string;
extensionPath?: string;
getYomitanParserWindow: () => unknown;
setYomitanParserWindow: (window: unknown) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
setYomitanExtension: (extension: unknown) => void;
setYomitanSession: (session: unknown) => void;
}) => Promise<unknown>;
const extension = await loadYomitanExtension({
userDataPath,
extensionPath,
getYomitanParserWindow: () => state.parserWindow,
setYomitanParserWindow: (window) => {
state.parserWindow = window;
},
setYomitanParserReadyPromise: (promise) => {
state.parserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
state.parserInitPromise = promise;
},
setYomitanExtension: (loaded) => {
state.yomitanExt = loaded;
},
setYomitanSession: (nextSession) => {
state.yomitanSession = nextSession;
},
});
if (!extension) {
state.note = 'yomitan extension is not available';
return state;
}
state.yomitanExt = extension;
state.available = true;
return state;
} catch (error) {
state.note = error instanceof Error ? error.message : 'failed to initialize yomitan extension';
return state;
}
}
export async function createYomitanRuntimeStateWithSearch(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const resolvedExtensionPath = resolveBuiltYomitanExtensionPath({
explicitPath: extensionPath,
cwd: process.cwd(),
});
if (resolvedExtensionPath) {
try {
if (fs.existsSync(path.join(resolvedExtensionPath, 'manifest.json'))) {
const state = await createYomitanRuntimeState(userDataPath, resolvedExtensionPath);
if (!state.available && !state.note) {
state.note = `Failed to load yomitan extension at ${resolvedExtensionPath}`;
}
return state;
}
} catch {
// fall through to the unconstrained loader below
}
}
return createYomitanRuntimeState(userDataPath, resolvedExtensionPath ?? undefined);
}
@@ -384,3 +384,40 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
cleanup();
}
});
test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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();
}
});
+28 -15
View File
@@ -231,43 +231,56 @@ export class KnownWordCacheManager {
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 null;
return matches;
}
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);
for (const noteId of noteIds) {
matches.add(noteId);
}
return tier;
}
const noReadingNotes = knownReadings.get(NO_READING_KEY);
if (noReadingNotes) {
tier = this.maxTierForNotes(tier, noReadingNotes);
return matches;
}
const exactReadingNotes = knownReadings.get(normalizedReading);
if (exactReadingNotes) {
tier = this.maxTierForNotes(tier, exactReadingNotes);
for (const key of [NO_READING_KEY, normalizedReading]) {
for (const noteId of knownReadings.get(key) ?? []) {
matches.add(noteId);
}
return tier;
}
return matches;
}
if (options?.allowReadingOnlyMatch === false) {
return null;
return matches;
}
const hiragana = convertKatakanaToHiragana(normalized);
if ([...hiragana].length === 1) {
return null;
return matches;
}
const readingNotes = this.readingNoteIds.get(hiragana);
return readingNotes ? this.maxTierForNotes(null, readingNotes) : null;
for (const noteId of this.readingNoteIds.get(hiragana) ?? []) {
matches.add(noteId);
}
return matches;
}
private maxTierForNotes(
@@ -63,7 +63,7 @@ export interface AnnotationStageOptions {
sourceText?: string;
}
function resolveKnownWordText(
export function resolveKnownWordText(
surface: string,
headword: string,
matchMode: NPlusOneMatchMode,
@@ -571,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;
@@ -584,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 {