diff --git a/README.md b/README.md
index 1efc593..1e6921d 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
[](./assets/minecard.mp4)
@@ -119,14 +116,14 @@ 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` |
-| [`bun`](https://bun.sh) (source builds, Linux wrapper) | `chafa`, `ffmpegthumbnailer` |
-| Linux: `hyprctl` or `xdotool` + `xwininfo` | |
-| macOS: Accessibility permission | |
+| Required | Optional |
+| ------------------------------------------------------ | ----------------------------- |
+| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
+| `ffmpeg` | `guessit` (AniSkip detection) |
+| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
+| [`bun`](https://bun.sh) (source builds, Linux wrapper) | `chafa`, `ffmpegthumbnailer` |
+| Linux: `hyprctl` or `xdotool` + `xwininfo` | |
+| macOS: Accessibility permission | |
Windows uses native window tracking and does not need the Linux compositor tools.
diff --git a/backlog/tasks/task-204 - Make-known-word-cache-incremental-and-avoid-full-rebuilds.md b/backlog/tasks/task-204 - Make-known-word-cache-incremental-and-avoid-full-rebuilds.md
new file mode 100644
index 0000000..18e7da2
--- /dev/null
+++ b/backlog/tasks/task-204 - Make-known-word-cache-incremental-and-avoid-full-rebuilds.md
@@ -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
+
+
+
+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.
+
+
+
+## Acceptance Criteria
+
+
+
+- [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.
+
+
+## Outcome
+
+
+
+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`
+
+
diff --git a/changes/2026-03-19-incremental-known-word-cache.md b/changes/2026-03-19-incremental-known-word-cache.md
new file mode 100644
index 0000000..85cfc07
--- /dev/null
+++ b/changes/2026-03-19-incremental-known-word-cache.md
@@ -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`.
diff --git a/config.example.jsonc b/config.example.jsonc
index b4cebc7..bf713e6 100644
--- a/config.example.jsonc
+++ b/config.example.jsonc
@@ -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.
diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc
index b4cebc7..bf713e6 100644
--- a/docs-site/public/config.example.jsonc
+++ b/docs-site/public/config.example.jsonc
@@ -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.
diff --git a/docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md b/docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md
new file mode 100644
index 0000000..7885cc9
--- /dev/null
+++ b/docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md
@@ -0,0 +1,46 @@
+
+
+# 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
diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts
index 3e9cbf4..ba3aea5 100644
--- a/launcher/commands/command-modules.test.ts
+++ b/launcher/commands/command-modules.test.ts
@@ -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;
diff --git a/launcher/commands/doctor-command.ts b/launcher/commands/doctor-command.ts
index b070ab9..6931bea 100644
--- a/launcher/commands/doctor-command.ts
+++ b/launcher/commands/doctor-command.ts
@@ -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;
}
diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts
index b2db497..08e4e2e 100644
--- a/launcher/config/args-normalizer.ts
+++ b/launcher/config/args-normalizer.ts
@@ -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) {
diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts
index 81c435b..b230dfe 100644
--- a/launcher/config/cli-parser-builder.ts
+++ b/launcher/config/cli-parser-builder.ts
@@ -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
', 'Log level')
.action((options: Record) => {
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,
},
diff --git a/launcher/main.test.ts b/launcher/main.test.ts
index 83751a0..de06557 100644
--- a/launcher/main.test.ts
+++ b/launcher/main.test.ts
@@ -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');
diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts
index 8eef0c6..f9ced26 100644
--- a/launcher/mpv.test.ts
+++ b/launcher/mpv.test.ts
@@ -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 {
dictionary: false,
stats: false,
doctor: false,
+ doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,
diff --git a/launcher/mpv.ts b/launcher/mpv.ts
index dc82a04..b747078 100644
--- a/launcher/mpv.ts
+++ b/launcher/mpv.ts
@@ -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
diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts
index 14c15ec..863bd1a 100644
--- a/launcher/parse-args.test.ts
+++ b/launcher/parse-args.test.ts
@@ -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);
+});
diff --git a/launcher/types.ts b/launcher/types.ts
index 17aa9e3..375494f 100644
--- a/launcher/types.ts
+++ b/launcher/types.ts
@@ -119,6 +119,7 @@ export interface Args {
statsCleanupLifetime?: boolean;
dictionaryTarget?: string;
doctor: boolean;
+ doctorRefreshKnownWords: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts
new file mode 100644
index 0000000..19aa735
--- /dev/null
+++ b/src/anki-connect.test.ts
@@ -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 };
+ sleep: (ms: number) => Promise;
+ };
+ 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;
+ }
+});
diff --git a/src/anki-connect.ts b/src/anki-connect.ts
index f4b5819..ec5107b 100644
--- a/src/anki-connect.ts
+++ b/src/anki-connect.ts
@@ -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);
}
diff --git a/src/anki-integration/known-word-cache.test.ts b/src/anki-integration/known-word-cache.test.ts
index 14c3946..d38afb6 100644
--- a/src/anki-integration/known-word-cache.test.ts
+++ b/src/anki-integration/known-word-cache.test.ts
@@ -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 }>;
+ };
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 }>,
+ };
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;
+ };
+ 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();
+ }
+});
diff --git a/src/anki-integration/known-word-cache.ts b/src/anki-integration/known-word-cache.ts
index 06ffd77..d29463c 100644
--- a/src/anki-integration/known-word-cache.ts
+++ b/src/anki-integration/known-word-cache.ts
@@ -64,13 +64,23 @@ export interface KnownWordCacheNoteInfo {
fields: Record;
}
-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;
+}
+
+type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
+
interface KnownWordCacheClient {
findNotes: (
query: string,
@@ -92,7 +102,10 @@ export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
private knownWords: Set = new Set();
+ private wordReferenceCounts = new Map();
+ private noteWordsById = new Map();
private knownWordsRefreshTimer: ReturnType | null = null;
+ private knownWordsRefreshTimeout: ReturnType | 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}`,
- `scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
- );
+ if (this.knownWordsLastRefreshedAtMs <= 0) {
+ this.knownWordsLastRefreshedAtMs = Date.now();
}
+ this.persistKnownWordCacheState();
+ log.info(
+ 'Known-word cache updated in-session',
+ `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();
- 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 {
+ 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 {
+ 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();
- for (const value of parsed.words) {
- const normalized = this.normalizeKnownWordForLookup(value);
- if (normalized) {
- nextKnownWords.add(normalized);
+ 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) {
+ 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 = {};
+ 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;
- if (candidate.version !== 1) return false;
+ const candidate = value as Record;
+ 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).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;
diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts
index a8c20ca..cbc9d70 100644
--- a/src/cli/args.test.ts
+++ b/src/cli/args.test.ts
@@ -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);
diff --git a/src/cli/args.ts b/src/cli/args.ts
index 9988f2a..ad05bc5 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -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 ||
diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts
index ed8b1b0..f253da5 100644
--- a/src/cli/help.test.ts
+++ b/src/cli/help.test.ts
@@ -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/);
diff --git a/src/cli/help.ts b/src/cli/help.ts
index a7bef77..3cb9731 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -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}
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index af8bc27..e53283c 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -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', () => {
diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts
index dfd58d3..d6074d7 100644
--- a/src/config/definitions/defaults-integrations.ts
+++ b/src/config/definitions/defaults-integrations.ts
@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
knownWords: {
highlightEnabled: false,
refreshMinutes: 1440,
+ addMinedWordsImmediately: true,
matchMode: 'headword',
decks: {},
color: '#a6da95',
diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts
index f0470f4..e884aa1 100644
--- a/src/config/definitions/options-integrations.ts
+++ b/src/config/definitions/options-integrations.ts
@@ -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',
diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts
index 0c0a944..0755fe8 100644
--- a/src/config/resolve/anki-connect.test.ts
+++ b/src/config/resolve/anki-connect.test.ts
@@ -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'] },
diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts
index b306ab6..c46b18f 100644
--- a/src/config/resolve/anki-connect.ts
+++ b/src/config/resolve/anki-connect.ts
@@ -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 &&
diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts
index 17fc5fc..a2539ab 100644
--- a/src/core/services/cli-command.test.ts
+++ b/src/core/services/cli-command.test.ts
@@ -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'));
});
diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts
index 7b9c2a2..53fd819 100644
--- a/src/core/services/cli-command.ts
+++ b/src/core/services/cli-command.ts
@@ -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) {
diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts
index 986a1d9..b9f8354 100644
--- a/src/core/services/overlay-runtime-init.test.ts
+++ b/src/core/services/overlay-runtime-init.test.ts
@@ -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,
diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts
index 50e4703..bbe8405 100644
--- a/src/core/services/overlay-runtime-init.ts
+++ b/src/core/services/overlay-runtime-init.ts
@@ -75,6 +75,7 @@ export function initializeOverlayRuntime(options: {
data: KikuFieldGroupingRequestData,
) => Promise;
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(),
});
- integration.start();
+ if (options.shouldStartAnkiIntegration?.() !== false) {
+ integration.start();
+ }
options.setAnkiIntegration(integration);
}
diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts
index 60e00fb..246972d 100644
--- a/src/core/services/startup.test.ts
+++ b/src/core/services/startup.test.ts
@@ -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',
+ ]);
+});
diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts
index 52198b0..8043860 100644
--- a/src/core/services/startup.ts
+++ b/src/core/services/startup.ts
@@ -131,10 +131,12 @@ export interface AppReadyRuntimeDeps {
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
initializeOverlayRuntime: () => void;
+ runHeadlessInitialCommand?: () => Promise;
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 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();
diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts
index 4a54f22..e2cbb46 100644
--- a/src/core/services/stats-server.ts
+++ b/src/core/services/stats-server.ts
@@ -68,7 +68,9 @@ function loadKnownWordsSet(cachePath: string | undefined): Set | 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 */
}
diff --git a/src/main.ts b/src/main.ts
index 491fe18..b489e24 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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 {
+ 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,13 +3031,16 @@ 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 &&
- (appState.initialArgs?.statsCleanup ||
- appState.initialArgs?.statsBackground ||
- appState.initialArgs?.statsStop),
+ (appState.initialArgs?.statsCleanup ||
+ appState.initialArgs?.statsBackground ||
+ appState.initialArgs?.statsStop),
),
shouldSkipHeavyStartup: () =>
Boolean(
@@ -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({
diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts
index 8b91052..d0274bf 100644
--- a/src/main/app-lifecycle.ts
+++ b/src/main/app-lifecycle.ts
@@ -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,
};
diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts
index b4ef3a4..be13fce 100644
--- a/src/main/runtime/app-ready-main-deps.ts
+++ b/src/main/runtime/app-ready-main-deps.ts
@@ -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,
});
diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts
index 3a72302..50062a3 100644
--- a/src/main/runtime/initial-args-handler.test.ts
+++ b/src/main/runtime/initial-args-handler.test.ts
@@ -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);
+});
diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts
index 6d777ff..119f8da 100644
--- a/src/main/runtime/initial-args-handler.ts
+++ b/src/main/runtime/initial-args-handler.ts
@@ -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() &&
diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts
index ab7d6c9..d4b3675 100644
--- a/src/main/runtime/initial-args-main-deps.test.ts
+++ b/src/main/runtime/initial-args-main-deps.test.ts
@@ -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);
diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts
index 96670c9..c25acab 100644
--- a/src/main/runtime/initial-args-main-deps.ts
+++ b/src/main/runtime/initial-args-main-deps.ts
@@ -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(),
diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts
index b243b97..16aa6c6 100644
--- a/src/main/runtime/initial-args-runtime-handler.test.ts
+++ b/src/main/runtime/initial-args-runtime-handler.test.ts
@@ -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,
diff --git a/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts b/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts
index 4e73a59..3857be2 100644
--- a/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts
+++ b/src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts
@@ -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,
diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts
index 74c4420..cdc6832 100644
--- a/src/main/runtime/overlay-runtime-bootstrap.ts
+++ b/src/main/runtime/overlay-runtime-bootstrap.ts
@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
data: KikuFieldGroupingRequestData,
) => Promise;
getKnownWordCacheStatePath: () => string;
+ shouldStartAnkiIntegration: () => boolean;
}) => void;
export function createInitializeOverlayRuntimeHandler(deps: {
diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts
index 0a69adb..c243e13 100644
--- a/src/main/runtime/overlay-runtime-options-main-deps.test.ts
+++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts
@@ -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();
diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts
index 8baa009..3022e06 100644
--- a/src/main/runtime/overlay-runtime-options-main-deps.ts
+++ b/src/main/runtime/overlay-runtime-options-main-deps.ts
@@ -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(),
});
}
diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts
index 90ff1d3..b3f20e8 100644
--- a/src/main/runtime/overlay-runtime-options.test.ts
+++ b/src/main/runtime/overlay-runtime-options.test.ts
@@ -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 });
diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts
index 664588b..7a2cea9 100644
--- a/src/main/runtime/overlay-runtime-options.ts
+++ b/src/main/runtime/overlay-runtime-options.ts
@@ -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;
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;
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,
});
}
diff --git a/src/types.ts b/src/types.ts
index 899caaf..dda74d4 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -249,6 +249,7 @@ export interface AnkiConnectConfig {
knownWords?: {
highlightEnabled?: boolean;
refreshMinutes?: number;
+ addMinedWordsImmediately?: boolean;
matchMode?: NPlusOneMatchMode;
decks?: Record;
color?: string;
@@ -754,6 +755,7 @@ export interface ResolvedConfig {
knownWords: {
highlightEnabled: boolean;
refreshMinutes: number;
+ addMinedWordsImmediately: boolean;
matchMode: NPlusOneMatchMode;
decks: Record;
color: string;