Switch known-word cache to incremental sync and doctor refresh

- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync
- Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates
- Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
This commit is contained in:
2026-03-19 19:29:58 -07:00
parent 72d78ba1ca
commit 20f53c0b70
50 changed files with 1130 additions and 119 deletions

View File

@@ -16,9 +16,6 @@
SubMiner is an Electron overlay for [mpv](https://mpv.io) that turns video into a sentence-mining workstation. Look up any word with [Yomitan](https://github.com/yomidevs/yomitan), mine it to Anki with one key, and track your immersion over time.
> [!NOTE]
> Release prep target: `v0.7.0`. This cut rolls the new stats/dashboard workflow, browser/daemon stats commands, dashboard mining actions, and the latest overlay/runtime stability fixes into the next 0-ver minor line.
<div align="center">
[![SubMiner demo (Animated preview)](./assets/minecard.webp)](./assets/minecard.mp4)
@@ -120,7 +117,7 @@ subminer stats cleanup # repair/prune stored stats vocabulary rows
## Requirements
| Required | Optional |
|---|---|
| ------------------------------------------------------ | ----------------------------- |
| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
| `ffmpeg` | `guessit` (AniSkip detection) |
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |

View File

@@ -0,0 +1,60 @@
---
id: TASK-204
title: Make known-word cache incremental and avoid full rebuilds
status: Done
assignee:
- Codex
created_date: '2026-03-19 19:05'
updated_date: '2026-03-19 19:12'
labels:
- anki
- cache
- performance
dependencies: []
references:
- src/anki-integration/known-word-cache.ts
- src/anki-integration.ts
- src/config/resolve/anki-connect.ts
- src/config/definitions/defaults-integrations.ts
priority: high
ordinal: 105722
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the known-word cache rebuild behavior with incremental synchronization. Startup should load existing cache state without immediately pulling all tracked Anki notes. Config-timed sync should reconcile adds, deletes, and in-place field edits against cached per-note state. Mined cards should optionally append their extracted words immediately after mining, enabled by default. Full rebuild should remain available only through explicit doctor tooling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Known-word cache startup no longer performs an automatic full rebuild.
- [x] #2 Config-timed sync incrementally reconciles note additions, deletions, and edited word fields for the tracked known-word deck scope.
- [x] #3 Newly mined cards update the known-word cache immediately when the new config flag is enabled, and skip that fast path when disabled.
- [x] #4 Persisted cache state remains usable by stats endpoints that read the `words` set from disk.
- [x] #5 Regression tests cover startup behavior, incremental sync diffs, and the new config flag.
<!-- AC:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Known-word cache startup now loads persisted state and schedules sync based on refresh timing instead of wiping and rebuilding immediately. Persisted cache state now includes per-note word snapshots so timed refreshes can remove deleted notes, update edited notes, and keep the global `words` set stable for stats consumers. Added `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`, so newly mined cards can update the cache immediately without waiting for the next timed sync.
Verification:
- `bun test src/anki-integration/known-word-cache.test.ts`
- `bun test src/config/resolve/anki-connect.test.ts src/config/config.test.ts`
- `bun test src/anki-integration.test.ts src/anki-integration/runtime.test.ts src/core/services/__tests__/stats-server.test.ts`
- `bun run test:config:src`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,4 @@
type: fixed
area: anki
- Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.

View File

@@ -348,6 +348,7 @@
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights.

View File

@@ -348,6 +348,7 @@
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights.

View File

@@ -0,0 +1,46 @@
<!-- read_when: changing known-word cache lifecycle, stats cache semantics, or Anki sync behavior -->
# Incremental Known-Word Cache Sync
## Goal
Stop rebuilding the entire known-word cache on startup or routine refreshes. Keep the cache correct through incremental reconciliation on the configured sync cadence, with an immediate append path for freshly mined cards.
## Scope
- Persist per-note extracted known-word snapshots beside the existing global `words` list.
- Replace startup refresh with load-only behavior.
- Make timed refresh diff current Anki note IDs against cached note IDs, then apply add/remove/edit deltas.
- Add `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`.
- Keep full rebuild out of normal lifecycle; reserve it for explicit doctor tooling.
## Data Model
Persist versioned cache state with:
- `words`: deduplicated global known-word set for stats/UI consumers
- `notes`: record of `noteId -> extractedWords[]`
- `refreshedAtMs`
- `scope`
The in-memory manager derives the global set from the per-note snapshots during sync updates so deletes and edits can remove stale words safely.
## Sync Behavior
- Startup: load persisted state only
- Interval tick or explicit refresh command: run incremental sync
- Incremental sync:
- query tracked note IDs for configured deck scope
- remove note snapshots for note IDs that disappeared
- fetch `notesInfo` for note IDs that are new or need field reconciliation
- compare extracted words per note and update the global set
## Immediate Mining Path
When SubMiner already has fresh `noteInfo` after mining or updating a note, append/update that note snapshot immediately if `addMinedWordsImmediately` is enabled.
## Verification
- focused cache manager tests for add/delete/edit reconciliation
- focused integration/config tests for startup behavior and new config flag
- config verification lane because defaults/schema/example change

View File

@@ -77,11 +77,37 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: () => {
throw new Error('unexpected app handoff');
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 1,
);
});
test('doctor command forwards refresh-known-words to app binary', () => {
const context = createContext();
context.args.doctor = true;
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
assert.throws(
() =>
runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext();
context.args.mpvStatus = true;

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { log } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js';
import { resolveMainConfigPath } from '../config-path.js';
import type { LauncherCommandContext } from './context.js';
@@ -8,12 +9,14 @@ interface DoctorCommandDeps {
commandExists(command: string): boolean;
configExists(path: string): boolean;
resolveMainConfigPath(): string;
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
}
const defaultDeps: DoctorCommandDeps = {
commandExists,
configExists: fs.existsSync,
resolveMainConfigPath,
runAppCommandWithInherit,
};
export function runDoctorCommand(
@@ -72,14 +75,21 @@ export function runDoctorCommand(
},
];
const hasHardFailure = checks.some((entry) =>
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
);
for (const check of checks) {
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
}
if (args.doctorRefreshKnownWords) {
if (!appPath) {
processAdapter.exit(1);
return true;
}
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
}
const hasHardFailure = checks.some((entry) =>
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
);
processAdapter.exit(hasHardFailure ? 1 : 0);
return true;
}

View File

@@ -129,6 +129,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
statsCleanupVocab: false,
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -206,6 +207,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.jellyfinInvocation) {

View File

@@ -49,6 +49,7 @@ export interface CliInvocations {
statsLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean;
texthookerLogLevel: string | null;
}
@@ -156,6 +157,7 @@ export function parseCliPrograms(
let statsCleanupLifetime = false;
let statsLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let texthookerLogLevel: string | null = null;
let doctorTriggered = false;
let texthookerTriggered = false;
@@ -304,10 +306,12 @@ export function parseCliPrograms(
commandProgram
.command('doctor')
.description('Run dependency and environment checks')
.option('--refresh-known-words', 'Refresh known words cache')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
doctorTriggered = true;
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
doctorRefreshKnownWords = options.refreshKnownWords === true;
});
commandProgram
@@ -388,6 +392,7 @@ export function parseCliPrograms(
statsLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,
texthookerTriggered,
texthookerLogLevel,
},

View File

@@ -178,6 +178,33 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
});
});
test('doctor refresh-known-words forwards app refresh command without requiring mpv', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: '',
Path: '',
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['doctor', '--refresh-known-words'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--refresh-known-words\n');
assert.match(result.stdout, /\[doctor\] mpv: missing/);
});
});
test('youtube command rejects removed --mode option', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');

View File

@@ -40,6 +40,26 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
assert.equal(result.error, undefined);
});
test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => {
const original = process.env.ELECTRON_RUN_AS_NODE;
try {
process.env.ELECTRON_RUN_AS_NODE = '1';
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, '');
} finally {
if (original === undefined) {
delete process.env.ELECTRON_RUN_AS_NODE;
} else {
process.env.ELECTRON_RUN_AS_NODE = original;
}
}
});
test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {
@@ -137,6 +157,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
dictionary: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,

View File

@@ -661,7 +661,7 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: 'inherit',
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
env: buildAppEnv(),
});
state.overlayManagedByLauncher = true;
@@ -688,7 +688,10 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, { stdio: 'inherit' });
const result = spawnSync(appPath, overlayArgs, {
stdio: 'inherit',
env: buildAppEnv(),
});
process.exit(result.status ?? 0);
}
@@ -702,7 +705,10 @@ export function stopOverlay(args: Args): void {
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
spawnSync(state.appPath, stopArgs, { stdio: 'ignore' });
spawnSync(state.appPath, stopArgs, {
stdio: 'ignore',
env: buildAppEnv(),
});
if (state.overlayProc && !state.overlayProc.killed) {
try {
@@ -763,6 +769,7 @@ function buildAppEnv(): NodeJS.ProcessEnv {
...process.env,
SUBMINER_MPV_LOG: getMpvLogPath(),
};
delete env.ELECTRON_RUN_AS_NODE;
const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers

View File

@@ -127,3 +127,10 @@ test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
assert.equal(parsed.statsCleanupVocab, false);
assert.equal(parsed.statsCleanupLifetime, true);
});
test('parseArgs maps doctor refresh-known-words flag', () => {
const parsed = parseArgs(['doctor', '--refresh-known-words'], 'subminer', {});
assert.equal(parsed.doctor, true);
assert.equal(parsed.doctorRefreshKnownWords, true);
});

View File

@@ -119,6 +119,7 @@ export interface Args {
statsCleanupLifetime?: boolean;
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;

50
src/anki-connect.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnkiConnectClient } from './anki-connect';
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: {
defaults: {
httpAgent?: { options?: { keepAlive?: boolean } };
httpsAgent?: { options?: { keepAlive?: boolean } };
};
};
};
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
});
test('AnkiConnectClient includes action name in retry logs', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
sleep: (ms: number) => Promise<void>;
};
let shouldFail = true;
client.client = {
post: async () => {
if (shouldFail) {
shouldFail = false;
const error = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' });
throw error;
}
return { data: { result: [], error: null } };
},
};
client.sleep = async () => undefined;
const originalInfo = console.info;
const messages: string[] = [];
try {
console.info = (...args: unknown[]) => {
messages.push(args.map((value) => String(value)).join(' '));
};
await (client as unknown as AnkiConnectClient).invoke('notesInfo', { notes: [1] });
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
} finally {
console.info = originalInfo;
}
});

View File

@@ -43,7 +43,7 @@ export class AnkiConnectClient {
constructor(url: string) {
const httpAgent = new http.Agent({
keepAlive: true,
keepAlive: false,
keepAliveMsecs: 1000,
maxSockets: 5,
maxFreeSockets: 2,
@@ -51,7 +51,7 @@ export class AnkiConnectClient {
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAlive: false,
keepAliveMsecs: 1000,
maxSockets: 5,
maxFreeSockets: 2,
@@ -106,7 +106,7 @@ export class AnkiConnectClient {
try {
if (attempt > 0) {
const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs);
log.info(`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`);
log.info(`AnkiConnect ${action} retry ${attempt}/${maxRetries} after ${delay}ms delay`);
await this.sleep(delay);
}

View File

@@ -9,14 +9,37 @@ import { KnownWordCacheManager } from './known-word-cache';
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: {
findNotes: number;
notesInfo: number;
};
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
cleanup: () => void;
} {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
const calls = {
findNotes: 0,
notesInfo: 0,
};
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
};
const manager = new KnownWordCacheManager({
client: {
findNotes: async () => [],
notesInfo: async () => [],
findNotes: async () => {
calls.findNotes += 1;
return clientState.findNotesResult;
},
notesInfo: async (noteIds) => {
calls.notesInfo += 1;
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
@@ -25,12 +48,49 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
return {
manager,
calls,
statePath,
clientState,
cleanup: () => {
fs.rmSync(stateDir, { recursive: true, force: true });
},
};
}
test('KnownWordCacheManager startLifecycle loads persisted cache without immediate rebuild', () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
manager.startLifecycle();
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
} finally {
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
@@ -69,6 +129,70 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
}
});
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: 1,
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫', '犬'],
notes: {
'1': ['猫'],
'2': ['犬'],
},
}),
'utf-8',
);
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '鳥' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), false);
assert.equal(manager.isKnownWord('鳥'), true);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
words: string[];
notes?: Record<string, string[]>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
});
} finally {
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
const config: AnkiConnectConfig = {
fields: {
@@ -110,3 +234,27 @@ test('KnownWordCacheManager invalidates persisted cache when per-deck fields cha
cleanup();
}
});
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
addMinedWordsImmediately: false,
},
};
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(fs.existsSync(statePath), false);
} finally {
cleanup();
}
});

View File

@@ -64,13 +64,23 @@ export interface KnownWordCacheNoteInfo {
fields: Record<string, { value: string }>;
}
interface KnownWordCacheState {
interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
interface KnownWordCacheClient {
findNotes: (
query: string,
@@ -92,7 +102,10 @@ export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
private knownWords: Set<string> = new Set();
private wordReferenceCounts = new Map<string, number>();
private noteWordsById = new Map<number, string[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false;
private readonly statePath: string;
@@ -133,14 +146,14 @@ export class KnownWordCacheManager {
);
this.loadKnownWordCacheState();
void this.refreshKnownWords();
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
this.scheduleKnownWordRefreshLifecycle();
}
stopLifecycle(): void {
if (this.knownWordsRefreshTimeout) {
clearTimeout(this.knownWordsRefreshTimeout);
this.knownWordsRefreshTimeout = null;
}
if (this.knownWordsRefreshTimer) {
clearInterval(this.knownWordsRefreshTimer);
this.knownWordsRefreshTimer = null;
@@ -148,7 +161,7 @@ export class KnownWordCacheManager {
}
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
if (!this.isKnownWordCacheEnabled()) {
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
return;
}
@@ -160,32 +173,26 @@ export class KnownWordCacheManager {
this.knownWordsStateKey = currentStateKey;
}
let addedCount = 0;
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
const normalized = this.normalizeKnownWordForLookup(rawWord);
if (!normalized || this.knownWords.has(normalized)) {
continue;
}
this.knownWords.add(normalized);
addedCount += 1;
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo);
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
if (!changed) {
return;
}
if (addedCount > 0) {
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
this.persistKnownWordCacheState();
log.info(
'Known-word cache updated in-session',
`added=${addedCount}`,
`noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
);
}
}
clearKnownWordCacheState(): void {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
try {
if (fs.existsSync(this.statePath)) {
@@ -218,33 +225,38 @@ export class KnownWordCacheManager {
maxRetries: 0,
})) as number[];
const nextKnownWords = new Set<string>();
if (noteIds.length > 0) {
const chunkSize = 50;
for (let i = 0; i < noteIds.length; i += chunkSize) {
const chunk = noteIds.slice(i, i + chunkSize);
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
const currentNoteIds = Array.from(
new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
).sort((a, b) => a - b);
for (const noteInfo of notesInfo) {
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
const normalized = this.normalizeKnownWordForLookup(word);
if (normalized) {
nextKnownWords.add(normalized);
if (this.noteWordsById.size === 0) {
await this.rebuildFromCurrentNotes(currentNoteIds);
} else {
const currentNoteIdSet = new Set(currentNoteIds);
for (const noteId of Array.from(this.noteWordsById.keys())) {
if (!currentNoteIdSet.has(noteId)) {
this.removeNoteSnapshot(noteId);
}
}
if (currentNoteIds.length > 0) {
const noteInfos = await this.fetchKnownWordNotesInfo(currentNoteIds);
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo),
);
}
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.persistKnownWordCacheState();
log.info(
'Known-word cache refreshed',
`noteCount=${noteIds.length}`,
`wordCount=${nextKnownWords.size}`,
`noteCount=${currentNoteIds.length}`,
`wordCount=${this.knownWords.size}`,
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -258,6 +270,10 @@ export class KnownWordCacheManager {
return this.deps.getConfig().knownWords?.highlightEnabled === true;
}
private shouldAddMinedWordsImmediately(): boolean {
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
}
private getKnownWordRefreshIntervalMs(): number {
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
}
@@ -322,64 +338,193 @@ export class KnownWordCacheManager {
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
}
private scheduleKnownWordRefreshLifecycle(): void {
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
const scheduleInterval = () => {
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
};
const initialDelayMs = this.getMsUntilNextRefresh();
this.knownWordsRefreshTimeout = setTimeout(() => {
this.knownWordsRefreshTimeout = null;
void this.refreshKnownWords();
scheduleInterval();
}, initialDelayMs);
}
private getMsUntilNextRefresh(): number {
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
return 0;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
return 0;
}
const remainingMs =
this.getKnownWordRefreshIntervalMs() - (Date.now() - this.knownWordsLastRefreshedAtMs);
return Math.max(0, remainingMs);
}
private async rebuildFromCurrentNotes(noteIds: number[]): Promise<void> {
this.clearInMemoryState();
if (noteIds.length === 0) {
return;
}
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(noteInfo.noteId, this.extractNormalizedKnownWordsFromNoteInfo(noteInfo));
}
}
private async fetchKnownWordNotesInfo(noteIds: number[]): Promise<KnownWordCacheNoteInfo[]> {
const noteInfos: KnownWordCacheNoteInfo[] = [];
const chunkSize = 50;
for (let i = 0; i < noteIds.length; i += chunkSize) {
const chunk = noteIds.slice(i, i + chunkSize);
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
const chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[];
for (const noteInfo of chunkInfos) {
if (!noteInfo || !Number.isInteger(noteInfo.noteId) || noteInfo.noteId <= 0) {
continue;
}
noteInfos.push(noteInfo);
}
}
return noteInfos;
}
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords);
const previousWords = this.noteWordsById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) {
return false;
}
this.removeWordsFromCounts(previousWords);
if (normalizedWords.length > 0) {
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
} else {
this.noteWordsById.delete(noteId);
}
return true;
}
private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId);
if (!previousWords) {
return;
}
this.noteWordsById.delete(noteId);
this.removeWordsFromCounts(previousWords);
}
private addWordsToCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
this.wordReferenceCounts.set(word, nextCount);
this.knownWords.add(word);
}
}
private removeWordsFromCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
if (nextCount > 0) {
this.wordReferenceCounts.set(word, nextCount);
} else {
this.wordReferenceCounts.delete(word);
this.knownWords.delete(word);
}
}
}
private clearInMemoryState(): void {
this.knownWords = new Set();
this.wordReferenceCounts = new Map();
this.noteWordsById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
private loadKnownWordCacheState(): void {
try {
if (!fs.existsSync(this.statePath)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const raw = fs.readFileSync(this.statePath, 'utf-8');
if (!raw.trim()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
}
const nextKnownWords = new Set<string>();
this.clearInMemoryState();
if (parsed.version === 2) {
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
const noteId = Number.parseInt(noteIdKey, 10);
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedWords = normalizeKnownWordList(words);
if (normalizedWords.length === 0) {
continue;
}
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (normalized) {
nextKnownWords.add(normalized);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
}
}
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
} catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message);
this.knownWords = new Set();
this.knownWordsLastRefreshedAtMs = 0;
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
}
}
private persistKnownWordCacheState(): void {
try {
const state: KnownWordCacheState = {
version: 1,
const notes: Record<string, string[]> = {};
for (const [noteId, words] of this.noteWordsById.entries()) {
if (words.length > 0) {
notes[String(noteId)] = words;
}
}
const state: KnownWordCacheStateV2 = {
version: 2,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey,
words: Array.from(this.knownWords),
notes,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) {
@@ -389,18 +534,35 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Partial<KnownWordCacheState>;
if (candidate.version !== 1) return false;
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1 && candidate.version !== 2) return false;
if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false;
if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry) => typeof entry === 'string')) {
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false;
}
if (candidate.version === 2) {
if (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
Array.isArray(candidate.notes)
) {
return false;
}
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(entry) =>
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
)
) {
return false;
}
}
return true;
}
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
const words: string[] = [];
const configuredFields = this.getConfiguredFields();
for (const preferredField of configuredFields) {
@@ -410,12 +572,12 @@ export class KnownWordCacheManager {
const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue;
const extracted = this.normalizeRawKnownWordValue(raw);
if (extracted) {
words.push(extracted);
const normalized = this.normalizeKnownWordForLookup(raw);
if (normalized) {
words.push(normalized);
}
}
return words;
return normalizeKnownWordList(words);
}
private normalizeRawKnownWordValue(value: string): string {
@@ -430,6 +592,22 @@ export class KnownWordCacheManager {
}
}
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact;

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
hasExplicitCommand,
isHeadlessInitialCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
@@ -101,7 +102,8 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const refreshKnownWords = parseArgs(['--refresh-known-words']);
assert.equal(refreshKnownWords.help, false);
assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false);
assert.equal(shouldStartApp(refreshKnownWords), true);
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);

View File

@@ -376,6 +376,10 @@ export function hasExplicitCommand(args: CliArgs): boolean {
);
}
export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords;
}
export function shouldStartApp(args: CliArgs): boolean {
if (args.stop && !args.start) return false;
if (
@@ -391,6 +395,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||

View File

@@ -19,7 +19,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /default: 7777/);
assert.match(output, /--launch-mpv/);
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
assert.match(output, /--refresh-known-words/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);

View File

@@ -35,7 +35,6 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--refresh-known-words Refresh known words cache
--open-runtime-options Open runtime options palette
${B}AniList${R}

View File

@@ -1435,7 +1435,8 @@ test('validates ankiConnect knownWords behavior values', () => {
"ankiConnect": {
"knownWords": {
"highlightEnabled": "yes",
"refreshMinutes": -5
"refreshMinutes": -5,
"addMinedWordsImmediately": "no"
}
}
}`,
@@ -1456,6 +1457,13 @@ test('validates ankiConnect knownWords behavior values', () => {
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.highlightEnabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
assert.equal(
config.ankiConnect.knownWords.addMinedWordsImmediately,
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately,
);
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
);
});
test('accepts valid ankiConnect knownWords behavior values', () => {
@@ -1466,7 +1474,8 @@ test('accepts valid ankiConnect knownWords behavior values', () => {
"ankiConnect": {
"knownWords": {
"highlightEnabled": true,
"refreshMinutes": 120
"refreshMinutes": 120,
"addMinedWordsImmediately": false
}
}
}`,
@@ -1478,6 +1487,7 @@ test('accepts valid ankiConnect knownWords behavior values', () => {
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false);
});
test('validates ankiConnect n+1 minimum sentence word count', () => {

View File

@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
knownWords: {
highlightEnabled: false,
refreshMinutes: 1440,
addMinedWordsImmediately: true,
matchMode: 'headword',
decks: {},
color: '#a6da95',

View File

@@ -108,6 +108,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
description: 'Minutes between known-word cache refreshes.',
},
{
path: 'ankiConnect.knownWords.addMinedWordsImmediately',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
description: 'Immediately append newly mined card words into the known-word cache.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',

View File

@@ -70,6 +70,20 @@ test('accepts knownWords.decks object format with field arrays', () => {
);
});
test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
const { context, warnings } = makeContext({
knownWords: { addMinedWordsImmediately: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.addMinedWordsImmediately, false);
assert.equal(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.addMinedWordsImmediately'),
false,
);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },

View File

@@ -771,6 +771,24 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&

View File

@@ -539,8 +539,21 @@ test('handleCliCommand runs refresh-known-words command', () => {
assert.ok(calls.includes('refreshKnownWords'));
});
test('handleCliCommand stops app after headless initial refresh-known-words completes', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(makeArgs({ refreshKnownWords: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('refreshKnownWords'));
assert.ok(calls.includes('stopApp'));
});
test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
hasMainWindow: () => false,
refreshKnownWords: async () => {
throw new Error('refresh boom');
},
@@ -551,4 +564,5 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
assert.ok(calls.some((value) => value.startsWith('error:refreshKnownWords failed:')));
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
assert.ok(calls.includes('stopApp'));
});

View File

@@ -334,12 +334,18 @@ export function handleCliCommand(
'Update failed',
);
} else if (args.refreshKnownWords) {
runAsyncWithOsd(
() => deps.refreshKnownWords(),
deps,
'refreshKnownWords',
'Refresh known words failed',
);
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.refreshKnownWords()
.catch((err) => {
deps.error('refreshKnownWords failed:', err);
deps.showMpvOsd(`Refresh known words failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {

View File

@@ -109,6 +109,60 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
assert.equal(setIntegrationCalls, 1);
});
test('initializeOverlayRuntime can skip starting Anki integration transport', () => {
let createdIntegrations = 0;
let startedIntegrations = 0;
let setIntegrationCalls = 0;
initializeOverlayRuntime({
backendOverride: null,
createMainWindow: () => {},
registerGlobalShortcuts: () => {},
updateVisibleOverlayBounds: () => {},
isVisibleOverlayVisible: () => false,
updateVisibleOverlayVisibility: () => {},
getOverlayWindows: () => [],
syncOverlayShortcuts: () => {},
setWindowTracker: () => {},
getMpvSocketPath: () => '/tmp/mpv.sock',
createWindowTracker: () => null,
getResolvedConfig: () => ({
ankiConnect: { enabled: true } as never,
}),
getSubtitleTimingTracker: () => ({}),
getMpvClient: () => ({
send: () => {},
}),
getRuntimeOptionsManager: () => ({
getEffectiveAnkiConnectConfig: (config) => config as never,
}),
createAnkiIntegration: () => {
createdIntegrations += 1;
return {
start: () => {
startedIntegrations += 1;
},
};
},
setAnkiIntegration: () => {
setIntegrationCalls += 1;
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 7,
deleteNoteId: 8,
deleteDuplicate: false,
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
shouldStartAnkiIntegration: () => false,
});
assert.equal(createdIntegrations, 1);
assert.equal(startedIntegrations, 0);
assert.equal(setIntegrationCalls, 1);
});
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
initializeOverlayRuntime({
backendOverride: null,

View File

@@ -75,6 +75,7 @@ export function initializeOverlayRuntime(options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration?: () => boolean;
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
}): void {
options.createMainWindow();
@@ -135,7 +136,9 @@ export function initializeOverlayRuntime(options: {
createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
});
if (options.shouldStartAnkiIntegration?.() !== false) {
integration.start();
}
options.setAnkiIntegration(integration);
}

View File

@@ -93,3 +93,104 @@ test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while
assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']);
});
test('runAppReadyRuntime headless refresh bootstraps Anki runtime without UI startup', async () => {
const calls: string[] = [];
await runAppReadyRuntime({
ensureDefaultConfigBootstrap: () => {
calls.push('bootstrap');
},
loadSubtitlePosition: () => {
calls.push('load-subtitle-position');
},
resolveKeybindings: () => {
calls.push('resolve-keybindings');
},
createMpvClient: () => {
calls.push('create-mpv');
},
reloadConfig: () => {
calls.push('reload-config');
},
getResolvedConfig: () => ({}),
getConfigWarnings: () => [],
logConfigWarning: () => {
calls.push('config-warning');
},
setLogLevel: () => {
calls.push('set-log-level');
},
initRuntimeOptionsManager: () => {
calls.push('init-runtime-options');
},
setSecondarySubMode: () => {
calls.push('set-secondary-sub-mode');
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 0,
defaultAnnotationWebsocketPort: 0,
defaultTexthookerPort: 0,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {
calls.push('subtitle-ws');
},
startAnnotationWebsocket: () => {
calls.push('annotation-ws');
},
startTexthooker: () => {
calls.push('texthooker');
},
log: () => {
calls.push('log');
},
createMecabTokenizerAndCheck: async () => {
calls.push('mecab');
},
createSubtitleTimingTracker: () => {
calls.push('subtitle-timing');
},
createImmersionTracker: () => {
calls.push('immersion');
},
startJellyfinRemoteSession: async () => {
calls.push('jellyfin');
},
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('first-run');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm');
},
startBackgroundWarmups: () => {
calls.push('warmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {
calls.push('visible-overlay');
},
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
runHeadlessInitialCommand: async () => {
calls.push('run-headless-command');
},
handleInitialArgs: () => {
calls.push('handle-initial-args');
},
shouldRunHeadlessInitialCommand: () => true,
shouldUseMinimalStartup: () => false,
shouldSkipHeavyStartup: () => false,
});
assert.deepEqual(calls, [
'bootstrap',
'reload-config',
'init-runtime-options',
'run-headless-command',
]);
});

View File

@@ -131,10 +131,12 @@ export interface AppReadyRuntimeDeps {
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
initializeOverlayRuntime: () => void;
runHeadlessInitialCommand?: () => Promise<void>;
handleInitialArgs: () => void;
logDebug?: (message: string) => void;
onCriticalConfigErrors?: (errors: string[]) => void;
now?: () => number;
shouldRunHeadlessInitialCommand?: () => boolean;
shouldUseMinimalStartup?: () => boolean;
shouldSkipHeavyStartup?: () => boolean;
}
@@ -184,6 +186,20 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
deps.initRuntimeOptionsManager();
if (deps.runHeadlessInitialCommand) {
await deps.runHeadlessInitialCommand();
} else {
deps.createMpvClient();
deps.createSubtitleTimingTracker();
deps.initializeOverlayRuntime();
deps.handleInitialArgs();
}
return;
}
if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig();
deps.handleInitialArgs();

View File

@@ -68,7 +68,9 @@ function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
version?: number;
words?: string[];
};
if (raw.version === 1 && Array.isArray(raw.words)) return new Set(raw.words);
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
return new Set(raw.words);
}
} catch {
/* ignore */
}

View File

@@ -31,6 +31,7 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import { mergeAiConfig } from './ai/config';
function getPasswordStoreArg(argv: string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
@@ -102,8 +103,10 @@ import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
import { resolveDefaultLogFilePath } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
commandNeedsOverlayRuntime,
isHeadlessInitialCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
@@ -2837,6 +2840,50 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
logError: (message, error) => logger.error(message, error),
});
async function runHeadlessInitialCommand(): Promise<void> {
if (!appState.initialArgs?.refreshKnownWords) {
handleInitialArgs();
return;
}
const resolvedConfig = getResolvedConfig();
if (resolvedConfig.ankiConnect.enabled !== true) {
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
process.exitCode = 1;
requestAppQuit();
return;
}
const effectiveAnkiConfig =
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
resolvedConfig.ankiConnect;
const integration = new AnkiIntegration(
effectiveAnkiConfig,
new SubtitleTimingTracker(),
{ send: () => undefined } as never,
undefined,
undefined,
async () => ({
keepNoteId: 0,
deleteNoteId: 0,
deleteDuplicate: false,
cancelled: true,
}),
path.join(USER_DATA_PATH, 'known-words-cache.json'),
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
);
try {
await integration.refreshKnownWordCache();
} catch (error) {
logger.error('Headless known-word refresh failed:', error);
process.exitCode = 1;
} finally {
integration.stop();
requestAppQuit();
}
}
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
@@ -2984,7 +3031,10 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
handleInitialArgs: () => handleInitialArgs(),
shouldRunHeadlessInitialCommand: () =>
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () =>
Boolean(
appState.initialArgs?.stats &&
@@ -3096,6 +3146,7 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
hasImmersionTracker: () => Boolean(appState.immersionTracker),
@@ -4139,8 +4190,24 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
overlayShortcutsRuntime: {
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
},
createMainWindow: () => createMainWindow(),
registerGlobalShortcuts: () => registerGlobalShortcuts(),
createMainWindow: () => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return;
}
createMainWindow();
},
registerGlobalShortcuts: () => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return;
}
registerGlobalShortcuts();
},
createWindowTracker: (override, targetMpvSocketPath) => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return null;
}
return createWindowTrackerCore(override, targetMpvSocketPath);
},
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
updateVisibleOverlayBounds(geometry),
getOverlayWindows: () => getOverlayWindows(),
@@ -4148,6 +4215,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
@@ -4155,7 +4224,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
setOverlayRuntimeInitialized: (initialized) => {
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => startBackgroundWarmups(),
startBackgroundWarmups: () => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return;
}
startBackgroundWarmups();
},
},
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({

View File

@@ -51,10 +51,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
runHeadlessInitialCommand?: AppReadyRuntimeDeps['runHeadlessInitialCommand'];
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
logDebug?: AppReadyRuntimeDeps['logDebug'];
now?: AppReadyRuntimeDeps['now'];
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
}
@@ -115,10 +117,12 @@ export function createAppReadyRuntimeDeps(
params.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
initializeOverlayRuntime: params.initializeOverlayRuntime,
runHeadlessInitialCommand: params.runHeadlessInitialCommand,
handleInitialArgs: params.handleInitialArgs,
onCriticalConfigErrors: params.onCriticalConfigErrors,
logDebug: params.logDebug,
now: params.now,
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
};

View File

@@ -34,10 +34,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
initializeOverlayRuntime: deps.initializeOverlayRuntime,
runHeadlessInitialCommand: deps.runHeadlessInitialCommand,
handleInitialArgs: deps.handleInitialArgs,
onCriticalConfigErrors: deps.onCriticalConfigErrors,
logDebug: deps.logDebug,
now: deps.now,
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
});

View File

@@ -8,6 +8,7 @@ test('initial args handler no-ops without initial args', () => {
getInitialArgs: () => null,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -28,6 +29,7 @@ test('initial args handler ensures tray in background mode', () => {
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {
ensuredTray = true;
},
@@ -49,6 +51,7 @@ test('initial args handler auto-connects mpv when needed', () => {
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -75,6 +78,7 @@ test('initial args handler forwards args to cli handler', () => {
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -95,6 +99,7 @@ test('initial args handler can ensure tray outside background mode when requeste
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => {
ensuredTray = true;
},
@@ -108,3 +113,31 @@ test('initial args handler can ensure tray outside background mode when requeste
handleInitialArgs();
assert.equal(ensuredTray, true);
});
test('initial args handler skips tray and mpv auto-connect for headless refresh', () => {
let ensuredTray = false;
let connectCalls = 0;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => true,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => {
connectCalls += 1;
},
}),
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, false);
assert.equal(connectCalls, 0);
});

View File

@@ -9,6 +9,7 @@ export function createHandleInitialArgsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -19,13 +20,15 @@ export function createHandleInitialArgsHandler(deps: {
return (): void => {
const initialArgs = deps.getInitialArgs();
if (!initialArgs) return;
const runHeadless = deps.shouldRunHeadlessInitialCommand(initialArgs);
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
if (!runHeadless && (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup())) {
deps.ensureTray();
}
const mpvClient = deps.getMpvClient();
if (
!runHeadless &&
!deps.isTexthookerOnlyMode() &&
!initialArgs.stats &&
deps.hasImmersionTracker() &&

View File

@@ -10,6 +10,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
getInitialArgs: () => args,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('ensure-tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -21,6 +22,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.getInitialArgs(), args);
assert.equal(deps.isBackgroundMode(), true);
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
assert.equal(deps.shouldRunHeadlessInitialCommand(args), false);
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);

View File

@@ -4,6 +4,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -15,6 +16,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
getInitialArgs: () => deps.getInitialArgs(),
isBackgroundMode: () => deps.isBackgroundMode(),
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
shouldRunHeadlessInitialCommand: (args: CliArgs) => deps.shouldRunHeadlessInitialCommand(args),
ensureTray: () => deps.ensureTray(),
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),

View File

@@ -8,6 +8,7 @@ test('initial args runtime handler composes main deps and runs initial command f
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -35,6 +36,30 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
getInitialArgs: () => ({ stats: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
shouldRunHeadlessInitialCommand: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
getMpvClient: () => ({
connected: false,
connect: () => calls.push('connect'),
}),
logInfo: (message) => calls.push(`log:${message}`),
handleCliCommand: (_args, source) => calls.push(`cli:${source}`),
});
handleInitialArgs();
assert.deepEqual(calls, ['cli:initial']);
});
test('initial args runtime handler skips tray and mpv auto-connect for headless refresh', () => {
const calls: string[] = [];
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ refreshKnownWords: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => true,
shouldRunHeadlessInitialCommand: () => true,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,

View File

@@ -43,6 +43,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
cancelled: true,
}) as KikuFieldGroupingChoice,
getKnownWordCacheStatePath: () => '/tmp/known.json',
shouldStartAnkiIntegration: () => true,
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => initialized,

View File

@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
}) => void;
export function createInitializeOverlayRuntimeHandler(deps: {

View File

@@ -39,6 +39,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
cancelled: true,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
shouldStartAnkiIntegration: () => false,
});
const deps = build();
@@ -46,6 +47,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
assert.equal(deps.isVisibleOverlayVisible(), true);
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
assert.equal(deps.shouldStartAnkiIntegration(), false);
deps.createMainWindow();
deps.registerGlobalShortcuts();

View File

@@ -33,10 +33,12 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
height: number;
}) => void;
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
}) {
return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride,
@@ -56,6 +58,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
setWindowTracker: (tracker) => {
deps.appState.windowTracker = tracker;
},
createWindowTracker: deps.createWindowTracker,
getResolvedConfig: () => deps.getResolvedConfig(),
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
getMpvClient: () => deps.appState.mpvClient,
@@ -67,5 +70,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
showDesktopNotification: deps.showDesktopNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
});
}

View File

@@ -28,6 +28,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
cancelled: false,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
shouldStartAnkiIntegration: () => true,
});
const options = buildOptions();
@@ -35,6 +36,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
assert.equal(options.isVisibleOverlayVisible(), true);
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
assert.equal(options.shouldStartAnkiIntegration(), true);
options.createMainWindow();
options.registerGlobalShortcuts();
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });

View File

@@ -17,6 +17,10 @@ type OverlayRuntimeOptions = {
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
@@ -30,6 +34,7 @@ type OverlayRuntimeOptions = {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
};
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
@@ -42,6 +47,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
createWindowTracker?: (
override?: string | null,
targetMpvSocketPath?: string | null,
) => BaseWindowTracker | null;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
@@ -55,6 +64,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
}) {
return (): OverlayRuntimeOptions => ({
backendOverride: deps.getBackendOverride(),
@@ -66,6 +76,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getOverlayWindows: deps.getOverlayWindows,
syncOverlayShortcuts: deps.syncOverlayShortcuts,
setWindowTracker: deps.setWindowTracker,
createWindowTracker: deps.createWindowTracker,
getResolvedConfig: deps.getResolvedConfig,
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
getMpvClient: deps.getMpvClient,
@@ -75,5 +86,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
showDesktopNotification: deps.showDesktopNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
});
}

View File

@@ -249,6 +249,7 @@ export interface AnkiConnectConfig {
knownWords?: {
highlightEnabled?: boolean;
refreshMinutes?: number;
addMinedWordsImmediately?: boolean;
matchMode?: NPlusOneMatchMode;
decks?: Record<string, string[]>;
color?: string;
@@ -754,6 +755,7 @@ export interface ResolvedConfig {
knownWords: {
highlightEnabled: boolean;
refreshMinutes: number;
addMinedWordsImmediately: boolean;
matchMode: NPlusOneMatchMode;
decks: Record<string, string[]>;
color: string;