From 20f53c0b70c06803bf1d6e5a4ee402b0ce9db12c Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 19 Mar 2026 19:29:58 -0700 Subject: [PATCH] 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 --- README.md | 31 +- ...che-incremental-and-avoid-full-rebuilds.md | 60 ++++ ...2026-03-19-incremental-known-word-cache.md | 4 + config.example.jsonc | 1 + docs-site/public/config.example.jsonc | 1 + ...nown-word-cache-incremental-sync-design.md | 46 +++ launcher/commands/command-modules.test.ts | 26 ++ launcher/commands/doctor-command.ts | 18 +- launcher/config/args-normalizer.ts | 2 + launcher/config/cli-parser-builder.ts | 5 + launcher/main.test.ts | 27 ++ launcher/mpv.test.ts | 21 ++ launcher/mpv.ts | 13 +- launcher/parse-args.test.ts | 7 + launcher/types.ts | 1 + src/anki-connect.test.ts | 50 +++ src/anki-connect.ts | 6 +- src/anki-integration/known-word-cache.test.ts | 152 ++++++++- src/anki-integration/known-word-cache.ts | 318 ++++++++++++++---- src/cli/args.test.ts | 4 +- src/cli/args.ts | 5 + src/cli/help.test.ts | 2 +- src/cli/help.ts | 1 - src/config/config.test.ts | 14 +- .../definitions/defaults-integrations.ts | 1 + .../definitions/options-integrations.ts | 6 + src/config/resolve/anki-connect.test.ts | 14 + src/config/resolve/anki-connect.ts | 18 + src/core/services/cli-command.test.ts | 14 + src/core/services/cli-command.ts | 18 +- .../services/overlay-runtime-init.test.ts | 54 +++ src/core/services/overlay-runtime-init.ts | 5 +- src/core/services/startup.test.ts | 101 ++++++ src/core/services/startup.ts | 16 + src/core/services/stats-server.ts | 4 +- src/main.ts | 86 ++++- src/main/app-lifecycle.ts | 4 + src/main/runtime/app-ready-main-deps.ts | 2 + src/main/runtime/initial-args-handler.test.ts | 33 ++ src/main/runtime/initial-args-handler.ts | 5 +- .../runtime/initial-args-main-deps.test.ts | 2 + src/main/runtime/initial-args-main-deps.ts | 2 + .../initial-args-runtime-handler.test.ts | 25 ++ ...overlay-runtime-bootstrap-handlers.test.ts | 1 + src/main/runtime/overlay-runtime-bootstrap.ts | 1 + .../overlay-runtime-options-main-deps.test.ts | 2 + .../overlay-runtime-options-main-deps.ts | 4 + .../runtime/overlay-runtime-options.test.ts | 2 + src/main/runtime/overlay-runtime-options.ts | 12 + src/types.ts | 2 + 50 files changed, 1130 insertions(+), 119 deletions(-) create mode 100644 backlog/tasks/task-204 - Make-known-word-cache-incremental-and-avoid-full-rebuilds.md create mode 100644 changes/2026-03-19-incremental-known-word-cache.md create mode 100644 docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md create mode 100644 src/anki-connect.test.ts diff --git a/README.md b/README.md index 1efc593..1e6921d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@
SubMiner logo - # SubMiner +# SubMiner - **Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.** +**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.** - [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) - [![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)]() - [![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) - [![AUR](https://img.shields.io/aur/version/subminer-bin)](https://aur.archlinux.org/packages/subminer-bin) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)]() +[![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) +[![AUR](https://img.shields.io/aur/version/subminer-bin)](https://aur.archlinux.org/packages/subminer-bin)
@@ -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. -
[![SubMiner demo (Animated preview)](./assets/minecard.webp)](./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;