mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Switch known-word cache to incremental sync and doctor refresh
- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync - Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates - Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
This commit is contained in:
17
README.md
17
README.md
@@ -1,14 +1,14 @@
|
||||
<div align="center">
|
||||
<img src="assets/SubMiner.png" width="140" alt="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.**
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[]()
|
||||
[](https://docs.subminer.moe)
|
||||
[](https://aur.archlinux.org/packages/subminer-bin)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[]()
|
||||
[](https://docs.subminer.moe)
|
||||
[](https://aur.archlinux.org/packages/subminer-bin)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
|
||||
SubMiner is an Electron overlay for [mpv](https://mpv.io) that turns video into a sentence-mining workstation. Look up any word with [Yomitan](https://github.com/yomidevs/yomitan), mine it to Anki with one key, and track your immersion over time.
|
||||
|
||||
> [!NOTE]
|
||||
> Release prep target: `v0.7.0`. This cut rolls the new stats/dashboard workflow, browser/daemon stats commands, dashboard mining actions, and the latest overlay/runtime stability fixes into the next 0-ver minor line.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](./assets/minecard.mp4)
|
||||
@@ -120,7 +117,7 @@ subminer stats cleanup # repair/prune stored stats vocabulary rows
|
||||
## Requirements
|
||||
|
||||
| Required | Optional |
|
||||
|---|---|
|
||||
| ------------------------------------------------------ | ----------------------------- |
|
||||
| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
|
||||
| `ffmpeg` | `guessit` (AniSkip detection) |
|
||||
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: TASK-204
|
||||
title: Make known-word cache incremental and avoid full rebuilds
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-19 19:05'
|
||||
updated_date: '2026-03-19 19:12'
|
||||
labels:
|
||||
- anki
|
||||
- cache
|
||||
- performance
|
||||
dependencies: []
|
||||
references:
|
||||
- src/anki-integration/known-word-cache.ts
|
||||
- src/anki-integration.ts
|
||||
- src/config/resolve/anki-connect.ts
|
||||
- src/config/definitions/defaults-integrations.ts
|
||||
priority: high
|
||||
ordinal: 105722
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Replace the known-word cache rebuild behavior with incremental synchronization. Startup should load existing cache state without immediately pulling all tracked Anki notes. Config-timed sync should reconcile adds, deletes, and in-place field edits against cached per-note state. Mined cards should optionally append their extracted words immediately after mining, enabled by default. Full rebuild should remain available only through explicit doctor tooling.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 Known-word cache startup no longer performs an automatic full rebuild.
|
||||
- [x] #2 Config-timed sync incrementally reconciles note additions, deletions, and edited word fields for the tracked known-word deck scope.
|
||||
- [x] #3 Newly mined cards update the known-word cache immediately when the new config flag is enabled, and skip that fast path when disabled.
|
||||
- [x] #4 Persisted cache state remains usable by stats endpoints that read the `words` set from disk.
|
||||
- [x] #5 Regression tests cover startup behavior, incremental sync diffs, and the new config flag.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
|
||||
Known-word cache startup now loads persisted state and schedules sync based on refresh timing instead of wiping and rebuilding immediately. Persisted cache state now includes per-note word snapshots so timed refreshes can remove deleted notes, update edited notes, and keep the global `words` set stable for stats consumers. Added `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`, so newly mined cards can update the cache immediately without waiting for the next timed sync.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun test src/anki-integration/known-word-cache.test.ts`
|
||||
- `bun test src/config/resolve/anki-connect.test.ts src/config/config.test.ts`
|
||||
- `bun test src/anki-integration.test.ts src/anki-integration/runtime.test.ts src/core/services/__tests__/stats-server.test.ts`
|
||||
- `bun run test:config:src`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
4
changes/2026-03-19-incremental-known-word-cache.md
Normal file
4
changes/2026-03-19-incremental-known-word-cache.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- read_when: changing known-word cache lifecycle, stats cache semantics, or Anki sync behavior -->
|
||||
|
||||
# Incremental Known-Word Cache Sync
|
||||
|
||||
## Goal
|
||||
|
||||
Stop rebuilding the entire known-word cache on startup or routine refreshes. Keep the cache correct through incremental reconciliation on the configured sync cadence, with an immediate append path for freshly mined cards.
|
||||
|
||||
## Scope
|
||||
|
||||
- Persist per-note extracted known-word snapshots beside the existing global `words` list.
|
||||
- Replace startup refresh with load-only behavior.
|
||||
- Make timed refresh diff current Anki note IDs against cached note IDs, then apply add/remove/edit deltas.
|
||||
- Add `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`.
|
||||
- Keep full rebuild out of normal lifecycle; reserve it for explicit doctor tooling.
|
||||
|
||||
## Data Model
|
||||
|
||||
Persist versioned cache state with:
|
||||
|
||||
- `words`: deduplicated global known-word set for stats/UI consumers
|
||||
- `notes`: record of `noteId -> extractedWords[]`
|
||||
- `refreshedAtMs`
|
||||
- `scope`
|
||||
|
||||
The in-memory manager derives the global set from the per-note snapshots during sync updates so deletes and edits can remove stale words safely.
|
||||
|
||||
## Sync Behavior
|
||||
|
||||
- Startup: load persisted state only
|
||||
- Interval tick or explicit refresh command: run incremental sync
|
||||
- Incremental sync:
|
||||
- query tracked note IDs for configured deck scope
|
||||
- remove note snapshots for note IDs that disappeared
|
||||
- fetch `notesInfo` for note IDs that are new or need field reconciliation
|
||||
- compare extracted words per note and update the global set
|
||||
|
||||
## Immediate Mining Path
|
||||
|
||||
When SubMiner already has fresh `noteInfo` after mining or updating a note, append/update that note snapshot immediately if `addMinedWordsImmediately` is enabled.
|
||||
|
||||
## Verification
|
||||
|
||||
- focused cache manager tests for add/delete/edit reconciliation
|
||||
- focused integration/config tests for startup behavior and new config flag
|
||||
- config verification lane because defaults/schema/example change
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface CliInvocations {
|
||||
statsLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
texthookerTriggered: boolean;
|
||||
texthookerLogLevel: string | null;
|
||||
}
|
||||
@@ -156,6 +157,7 @@ export function parseCliPrograms(
|
||||
let statsCleanupLifetime = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let doctorRefreshKnownWords = false;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
@@ -304,10 +306,12 @@ export function parseCliPrograms(
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
.option('--refresh-known-words', 'Refresh known words cache')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
doctorTriggered = true;
|
||||
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
doctorRefreshKnownWords = options.refreshKnownWords === true;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
@@ -388,6 +392,7 @@ export function parseCliPrograms(
|
||||
statsLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
doctorRefreshKnownWords,
|
||||
texthookerTriggered,
|
||||
texthookerLogLevel,
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -40,6 +40,26 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => {
|
||||
const original = process.env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
process.env.ELECTRON_RUN_AS_NODE = '1';
|
||||
const result = runAppCommandCaptureOutput(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stdout, '');
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
} else {
|
||||
process.env.ELECTRON_RUN_AS_NODE = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
try {
|
||||
@@ -137,6 +157,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface Args {
|
||||
statsCleanupLifetime?: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
50
src/anki-connect.test.ts
Normal file
50
src/anki-connect.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
|
||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: {
|
||||
defaults: {
|
||||
httpAgent?: { options?: { keepAlive?: boolean } };
|
||||
httpsAgent?: { options?: { keepAlive?: boolean } };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
|
||||
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
};
|
||||
let shouldFail = true;
|
||||
client.client = {
|
||||
post: async () => {
|
||||
if (shouldFail) {
|
||||
shouldFail = false;
|
||||
const error = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' });
|
||||
throw error;
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
client.sleep = async () => undefined;
|
||||
|
||||
const originalInfo = console.info;
|
||||
const messages: string[] = [];
|
||||
try {
|
||||
console.info = (...args: unknown[]) => {
|
||||
messages.push(args.map((value) => String(value)).join(' '));
|
||||
};
|
||||
|
||||
await (client as unknown as AnkiConnectClient).invoke('notesInfo', { notes: [1] });
|
||||
|
||||
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
|
||||
} finally {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,37 @@ import { KnownWordCacheManager } from './known-word-cache';
|
||||
|
||||
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||
manager: KnownWordCacheManager;
|
||||
calls: {
|
||||
findNotes: number;
|
||||
notesInfo: number;
|
||||
};
|
||||
statePath: string;
|
||||
clientState: {
|
||||
findNotesResult: number[];
|
||||
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
|
||||
const statePath = path.join(stateDir, 'known-words-cache.json');
|
||||
const calls = {
|
||||
findNotes: 0,
|
||||
notesInfo: 0,
|
||||
};
|
||||
const clientState = {
|
||||
findNotesResult: [] as number[],
|
||||
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
|
||||
};
|
||||
const manager = new KnownWordCacheManager({
|
||||
client: {
|
||||
findNotes: async () => [],
|
||||
notesInfo: async () => [],
|
||||
findNotes: async () => {
|
||||
calls.findNotes += 1;
|
||||
return clientState.findNotesResult;
|
||||
},
|
||||
notesInfo: async (noteIds) => {
|
||||
calls.notesInfo += 1;
|
||||
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
|
||||
},
|
||||
},
|
||||
getConfig: () => config,
|
||||
knownWordCacheStatePath: statePath,
|
||||
@@ -25,12 +48,49 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||
|
||||
return {
|
||||
manager,
|
||||
calls,
|
||||
statePath,
|
||||
clientState,
|
||||
cleanup: () => {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('KnownWordCacheManager startLifecycle loads persisted cache without immediate rebuild', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":""}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
} finally {
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
deck: 'Mining',
|
||||
@@ -69,6 +129,70 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: 1,
|
||||
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
|
||||
words: ['猫', '犬'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
'2': ['犬'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
(
|
||||
manager as unknown as {
|
||||
loadKnownWordCacheState: () => void;
|
||||
}
|
||||
).loadKnownWordCacheState();
|
||||
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '鳥' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(manager.isKnownWord('犬'), false);
|
||||
assert.equal(manager.isKnownWord('鳥'), true);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
words: string[];
|
||||
notes?: Record<string, string[]>;
|
||||
};
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
||||
assert.deepEqual(persisted.notes, {
|
||||
'1': ['鳥'],
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
@@ -110,3 +234,27 @@ test('KnownWordCacheManager invalidates persisted cache when per-deck fields cha
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
addMinedWordsImmediately: false,
|
||||
},
|
||||
};
|
||||
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
manager.appendFromNoteInfo({
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Expression: { value: '猫' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
assert.equal(fs.existsSync(statePath), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,13 +64,23 @@ export interface KnownWordCacheNoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
interface KnownWordCacheState {
|
||||
interface KnownWordCacheStateV1 {
|
||||
readonly version: 1;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
}
|
||||
|
||||
interface KnownWordCacheStateV2 {
|
||||
readonly version: 2;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly words: string[];
|
||||
readonly notes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
query: string,
|
||||
@@ -92,7 +102,10 @@ export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private wordReferenceCounts = new Map<string, number>();
|
||||
private noteWordsById = new Map<number, string[]>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
private readonly statePath: string;
|
||||
|
||||
@@ -133,14 +146,14 @@ export class KnownWordCacheManager {
|
||||
);
|
||||
|
||||
this.loadKnownWordCacheState();
|
||||
void this.refreshKnownWords();
|
||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||
this.knownWordsRefreshTimer = setInterval(() => {
|
||||
void this.refreshKnownWords();
|
||||
}, refreshIntervalMs);
|
||||
this.scheduleKnownWordRefreshLifecycle();
|
||||
}
|
||||
|
||||
stopLifecycle(): void {
|
||||
if (this.knownWordsRefreshTimeout) {
|
||||
clearTimeout(this.knownWordsRefreshTimeout);
|
||||
this.knownWordsRefreshTimeout = null;
|
||||
}
|
||||
if (this.knownWordsRefreshTimer) {
|
||||
clearInterval(this.knownWordsRefreshTimer);
|
||||
this.knownWordsRefreshTimer = null;
|
||||
@@ -148,7 +161,7 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,32 +173,26 @@ export class KnownWordCacheManager {
|
||||
this.knownWordsStateKey = currentStateKey;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(rawWord);
|
||||
if (!normalized || this.knownWords.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
addedCount += 1;
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedCount > 0) {
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
}
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`added=${addedCount}`,
|
||||
`noteId=${noteInfo.noteId}`,
|
||||
`wordCount=${nextWords.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clearKnownWordCacheState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
try {
|
||||
if (fs.existsSync(this.statePath)) {
|
||||
@@ -218,33 +225,38 @@ export class KnownWordCacheManager {
|
||||
maxRetries: 0,
|
||||
})) as number[];
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
if (noteIds.length > 0) {
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < noteIds.length; i += chunkSize) {
|
||||
const chunk = noteIds.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
|
||||
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[];
|
||||
const currentNoteIds = Array.from(
|
||||
new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
for (const noteInfo of notesInfo) {
|
||||
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
||||
const normalized = this.normalizeKnownWordForLookup(word);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
if (this.noteWordsById.size === 0) {
|
||||
await this.rebuildFromCurrentNotes(currentNoteIds);
|
||||
} else {
|
||||
const currentNoteIdSet = new Set(currentNoteIds);
|
||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||
if (!currentNoteIdSet.has(noteId)) {
|
||||
this.removeNoteSnapshot(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNoteIds.length > 0) {
|
||||
const noteInfos = await this.fetchKnownWordNotesInfo(currentNoteIds);
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
this.persistKnownWordCacheState();
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${noteIds.length}`,
|
||||
`wordCount=${nextKnownWords.size}`,
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.knownWords.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -258,6 +270,10 @@ export class KnownWordCacheManager {
|
||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||
}
|
||||
|
||||
private getKnownWordRefreshIntervalMs(): number {
|
||||
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
|
||||
}
|
||||
@@ -322,64 +338,193 @@ export class KnownWordCacheManager {
|
||||
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
|
||||
}
|
||||
|
||||
private scheduleKnownWordRefreshLifecycle(): void {
|
||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
||||
const scheduleInterval = () => {
|
||||
this.knownWordsRefreshTimer = setInterval(() => {
|
||||
void this.refreshKnownWords();
|
||||
}, refreshIntervalMs);
|
||||
};
|
||||
|
||||
const initialDelayMs = this.getMsUntilNextRefresh();
|
||||
this.knownWordsRefreshTimeout = setTimeout(() => {
|
||||
this.knownWordsRefreshTimeout = null;
|
||||
void this.refreshKnownWords();
|
||||
scheduleInterval();
|
||||
}, initialDelayMs);
|
||||
}
|
||||
|
||||
private getMsUntilNextRefresh(): number {
|
||||
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
|
||||
return 0;
|
||||
}
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const remainingMs =
|
||||
this.getKnownWordRefreshIntervalMs() - (Date.now() - this.knownWordsLastRefreshedAtMs);
|
||||
return Math.max(0, remainingMs);
|
||||
}
|
||||
|
||||
private async rebuildFromCurrentNotes(noteIds: number[]): Promise<void> {
|
||||
this.clearInMemoryState();
|
||||
if (noteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(noteInfo.noteId, this.extractNormalizedKnownWordsFromNoteInfo(noteInfo));
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchKnownWordNotesInfo(noteIds: number[]): Promise<KnownWordCacheNoteInfo[]> {
|
||||
const noteInfos: KnownWordCacheNoteInfo[] = [];
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < noteIds.length; i += chunkSize) {
|
||||
const chunk = noteIds.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
|
||||
const chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[];
|
||||
for (const noteInfo of chunkInfos) {
|
||||
if (!noteInfo || !Number.isInteger(noteInfo.noteId) || noteInfo.noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
noteInfos.push(noteInfo);
|
||||
}
|
||||
}
|
||||
return noteInfos;
|
||||
}
|
||||
|
||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
if (normalizedWords.length > 0) {
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
} else {
|
||||
this.noteWordsById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeNoteSnapshot(noteId: number): void {
|
||||
const previousWords = this.noteWordsById.get(noteId);
|
||||
if (!previousWords) {
|
||||
return;
|
||||
}
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
}
|
||||
|
||||
private addWordsToCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
this.knownWords.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
private removeWordsFromCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
} else {
|
||||
this.wordReferenceCounts.delete(word);
|
||||
this.knownWords.delete(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.wordReferenceCounts = new Map();
|
||||
this.noteWordsById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
private loadKnownWordCacheState(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.statePath)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(this.statePath, 'utf-8');
|
||||
if (!raw.trim()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKnownWords = new Set<string>();
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 2) {
|
||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedWords = normalizeKnownWordList(words);
|
||||
if (normalizedWords.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
}
|
||||
} else {
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (normalized) {
|
||||
nextKnownWords.add(normalized);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
this.wordReferenceCounts.set(normalized, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.knownWords = nextKnownWords;
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
} catch (error) {
|
||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||
this.knownWords = new Set();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
this.clearInMemoryState();
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
}
|
||||
}
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const state: KnownWordCacheState = {
|
||||
version: 1,
|
||||
const notes: Record<string, string[]> = {};
|
||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||
if (words.length > 0) {
|
||||
notes[String(noteId)] = words;
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV2 = {
|
||||
version: 2,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
words: Array.from(this.knownWords),
|
||||
notes,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
} catch (error) {
|
||||
@@ -389,18 +534,35 @@ export class KnownWordCacheManager {
|
||||
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Partial<KnownWordCacheState>;
|
||||
if (candidate.version !== 1) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry) => typeof entry === 'string')) {
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
Array.isArray(candidate.notes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
||||
private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
||||
const words: string[] = [];
|
||||
const configuredFields = this.getConfiguredFields();
|
||||
for (const preferredField of configuredFields) {
|
||||
@@ -410,12 +572,12 @@ export class KnownWordCacheManager {
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const extracted = this.normalizeRawKnownWordValue(raw);
|
||||
if (extracted) {
|
||||
words.push(extracted);
|
||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||
if (normalized) {
|
||||
words.push(normalized);
|
||||
}
|
||||
}
|
||||
return words;
|
||||
return normalizeKnownWordList(words);
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
@@ -430,6 +592,22 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnownWordList(words: string[]): string[] {
|
||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
||||
}
|
||||
|
||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
knownWords: {
|
||||
highlightEnabled: false,
|
||||
refreshMinutes: 1440,
|
||||
addMinedWordsImmediately: true,
|
||||
matchMode: 'headword',
|
||||
decks: {},
|
||||
color: '#a6da95',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,7 @@ export function initializeOverlayRuntime(options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): void {
|
||||
options.createMainWindow();
|
||||
@@ -135,7 +136,9 @@ export function initializeOverlayRuntime(options: {
|
||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
}
|
||||
options.setAnkiIntegration(integration);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -131,10 +131,12 @@ export interface AppReadyRuntimeDeps {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
runHeadlessInitialCommand?: () => Promise<void>;
|
||||
handleInitialArgs: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
now?: () => number;
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
@@ -184,6 +186,20 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldRunHeadlessInitialCommand?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.initRuntimeOptionsManager();
|
||||
if (deps.runHeadlessInitialCommand) {
|
||||
await deps.runHeadlessInitialCommand();
|
||||
} else {
|
||||
deps.createMpvClient();
|
||||
deps.createSubtitleTimingTracker();
|
||||
deps.initializeOverlayRuntime();
|
||||
deps.handleInitialArgs();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
|
||||
@@ -68,7 +68,9 @@ function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||
version?: number;
|
||||
words?: string[];
|
||||
};
|
||||
if (raw.version === 1 && Array.isArray(raw.words)) return new Set(raw.words);
|
||||
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||
return new Set(raw.words);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
80
src/main.ts
80
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<void> {
|
||||
if (!appState.initialArgs?.refreshKnownWords) {
|
||||
handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedConfig = getResolvedConfig();
|
||||
if (resolvedConfig.ankiConnect.enabled !== true) {
|
||||
logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
||||
process.exitCode = 1;
|
||||
requestAppQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveAnkiConfig =
|
||||
appState.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||
resolvedConfig.ankiConnect;
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
new SubtitleTimingTracker(),
|
||||
{ send: () => undefined } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: false,
|
||||
cancelled: true,
|
||||
}),
|
||||
path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
mergeAiConfig(resolvedConfig.ai, resolvedConfig.ankiConnect?.ai),
|
||||
);
|
||||
|
||||
try {
|
||||
await integration.refreshKnownWordCache();
|
||||
} catch (error) {
|
||||
logger.error('Headless known-word refresh failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
integration.stop();
|
||||
requestAppQuit();
|
||||
}
|
||||
}
|
||||
|
||||
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: {
|
||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||
@@ -2984,7 +3031,10 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
shouldRunHeadlessInitialCommand: () =>
|
||||
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
shouldUseMinimalStartup: () =>
|
||||
Boolean(
|
||||
appState.initialArgs?.stats &&
|
||||
@@ -3096,6 +3146,7 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
isBackgroundMode: () => appState.backgroundMode,
|
||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
||||
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
||||
ensureTray: () => ensureTray(),
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||
@@ -4139,8 +4190,24 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
createMainWindow: () => createMainWindow(),
|
||||
registerGlobalShortcuts: () => registerGlobalShortcuts(),
|
||||
createMainWindow: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
createMainWindow();
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return null;
|
||||
}
|
||||
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
updateVisibleOverlayBounds(geometry),
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
@@ -4148,6 +4215,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
showDesktopNotification,
|
||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
shouldStartAnkiIntegration: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
@@ -4155,7 +4224,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
||||
startBackgroundWarmups: () => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
startBackgroundWarmups();
|
||||
},
|
||||
},
|
||||
});
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -17,6 +17,10 @@ type OverlayRuntimeOptions = {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -30,6 +34,7 @@ type OverlayRuntimeOptions = {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
};
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
@@ -42,6 +47,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
createWindowTracker?: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
getSubtitleTimingTracker: () => unknown | null;
|
||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||
@@ -55,6 +64,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptions => ({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
@@ -66,6 +76,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
getOverlayWindows: deps.getOverlayWindows,
|
||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||
setWindowTracker: deps.setWindowTracker,
|
||||
createWindowTracker: deps.createWindowTracker,
|
||||
getResolvedConfig: deps.getResolvedConfig,
|
||||
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
|
||||
getMpvClient: deps.getMpvClient,
|
||||
@@ -75,5 +86,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
showDesktopNotification: deps.showDesktopNotification,
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ export interface AnkiConnectConfig {
|
||||
knownWords?: {
|
||||
highlightEnabled?: boolean;
|
||||
refreshMinutes?: number;
|
||||
addMinedWordsImmediately?: boolean;
|
||||
matchMode?: NPlusOneMatchMode;
|
||||
decks?: Record<string, string[]>;
|
||||
color?: string;
|
||||
@@ -754,6 +755,7 @@ export interface ResolvedConfig {
|
||||
knownWords: {
|
||||
highlightEnabled: boolean;
|
||||
refreshMinutes: number;
|
||||
addMinedWordsImmediately: boolean;
|
||||
matchMode: NPlusOneMatchMode;
|
||||
decks: Record<string, string[]>;
|
||||
color: string;
|
||||
|
||||
Reference in New Issue
Block a user