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">
|
<div align="center">
|
||||||
<img src="assets/SubMiner.png" width="140" alt="SubMiner logo">
|
<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://www.gnu.org/licenses/gpl-3.0)
|
||||||
[]()
|
[]()
|
||||||
[](https://docs.subminer.moe)
|
[](https://docs.subminer.moe)
|
||||||
[](https://aur.archlinux.org/packages/subminer-bin)
|
[](https://aur.archlinux.org/packages/subminer-bin)
|
||||||
|
|
||||||
</div>
|
</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.
|
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">
|
<div align="center">
|
||||||
|
|
||||||
[](./assets/minecard.mp4)
|
[](./assets/minecard.mp4)
|
||||||
@@ -120,7 +117,7 @@ subminer stats cleanup # repair/prune stored stats vocabulary rows
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| Required | Optional |
|
| Required | Optional |
|
||||||
|---|---|
|
| ------------------------------------------------------ | ----------------------------- |
|
||||||
| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
|
| [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
|
||||||
| `ffmpeg` | `guessit` (AniSkip detection) |
|
| `ffmpeg` | `guessit` (AniSkip detection) |
|
||||||
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
|
| `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": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"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
|
"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"] }.
|
"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.
|
"color": "#a6da95" // Color used for known-word highlights.
|
||||||
|
|||||||
@@ -348,6 +348,7 @@
|
|||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"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
|
"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"] }.
|
"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.
|
"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,
|
commandExists: () => false,
|
||||||
configExists: () => true,
|
configExists: () => true,
|
||||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
runAppCommandWithInherit: () => {
|
||||||
|
throw new Error('unexpected app handoff');
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
(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 () => {
|
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.mpvStatus = true;
|
context.args.mpvStatus = true;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { log } from '../log.js';
|
import { log } from '../log.js';
|
||||||
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
import { commandExists } from '../util.js';
|
import { commandExists } from '../util.js';
|
||||||
import { resolveMainConfigPath } from '../config-path.js';
|
import { resolveMainConfigPath } from '../config-path.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
@@ -8,12 +9,14 @@ interface DoctorCommandDeps {
|
|||||||
commandExists(command: string): boolean;
|
commandExists(command: string): boolean;
|
||||||
configExists(path: string): boolean;
|
configExists(path: string): boolean;
|
||||||
resolveMainConfigPath(): string;
|
resolveMainConfigPath(): string;
|
||||||
|
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: DoctorCommandDeps = {
|
const defaultDeps: DoctorCommandDeps = {
|
||||||
commandExists,
|
commandExists,
|
||||||
configExists: fs.existsSync,
|
configExists: fs.existsSync,
|
||||||
resolveMainConfigPath,
|
resolveMainConfigPath,
|
||||||
|
runAppCommandWithInherit,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function runDoctorCommand(
|
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) {
|
for (const check of checks) {
|
||||||
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
|
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);
|
processAdapter.exit(hasHardFailure ? 1 : 0);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -206,6 +207,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||||
}
|
}
|
||||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||||
|
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
|
||||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||||
|
|
||||||
if (invocations.jellyfinInvocation) {
|
if (invocations.jellyfinInvocation) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface CliInvocations {
|
|||||||
statsLogLevel: string | null;
|
statsLogLevel: string | null;
|
||||||
doctorTriggered: boolean;
|
doctorTriggered: boolean;
|
||||||
doctorLogLevel: string | null;
|
doctorLogLevel: string | null;
|
||||||
|
doctorRefreshKnownWords: boolean;
|
||||||
texthookerTriggered: boolean;
|
texthookerTriggered: boolean;
|
||||||
texthookerLogLevel: string | null;
|
texthookerLogLevel: string | null;
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,7 @@ export function parseCliPrograms(
|
|||||||
let statsCleanupLifetime = false;
|
let statsCleanupLifetime = false;
|
||||||
let statsLogLevel: string | null = null;
|
let statsLogLevel: string | null = null;
|
||||||
let doctorLogLevel: string | null = null;
|
let doctorLogLevel: string | null = null;
|
||||||
|
let doctorRefreshKnownWords = false;
|
||||||
let texthookerLogLevel: string | null = null;
|
let texthookerLogLevel: string | null = null;
|
||||||
let doctorTriggered = false;
|
let doctorTriggered = false;
|
||||||
let texthookerTriggered = false;
|
let texthookerTriggered = false;
|
||||||
@@ -304,10 +306,12 @@ export function parseCliPrograms(
|
|||||||
commandProgram
|
commandProgram
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.description('Run dependency and environment checks')
|
.description('Run dependency and environment checks')
|
||||||
|
.option('--refresh-known-words', 'Refresh known words cache')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((options: Record<string, unknown>) => {
|
.action((options: Record<string, unknown>) => {
|
||||||
doctorTriggered = true;
|
doctorTriggered = true;
|
||||||
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
|
doctorRefreshKnownWords = options.refreshKnownWords === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
@@ -388,6 +392,7 @@ export function parseCliPrograms(
|
|||||||
statsLogLevel,
|
statsLogLevel,
|
||||||
doctorTriggered,
|
doctorTriggered,
|
||||||
doctorLogLevel,
|
doctorLogLevel,
|
||||||
|
doctorRefreshKnownWords,
|
||||||
texthookerTriggered,
|
texthookerTriggered,
|
||||||
texthookerLogLevel,
|
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', () => {
|
test('youtube command rejects removed --mode option', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
@@ -40,6 +40,26 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
|
|||||||
assert.equal(result.error, undefined);
|
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 () => {
|
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
try {
|
try {
|
||||||
@@ -137,6 +157,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
dictionary: false,
|
dictionary: false,
|
||||||
stats: false,
|
stats: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
|
|||||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||||
state.overlayProc = spawn(target.command, target.args, {
|
state.overlayProc = spawn(target.command, target.args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
env: buildAppEnv(),
|
||||||
});
|
});
|
||||||
state.overlayManagedByLauncher = true;
|
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);
|
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||||
|
|
||||||
log('info', args.logLevel, 'Launching texthooker mode...');
|
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);
|
process.exit(result.status ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,7 +705,10 @@ export function stopOverlay(args: Args): void {
|
|||||||
const stopArgs = ['--stop'];
|
const stopArgs = ['--stop'];
|
||||||
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
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) {
|
if (state.overlayProc && !state.overlayProc.killed) {
|
||||||
try {
|
try {
|
||||||
@@ -763,6 +769,7 @@ function buildAppEnv(): NodeJS.ProcessEnv {
|
|||||||
...process.env,
|
...process.env,
|
||||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||||
};
|
};
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
const layers = env.VK_INSTANCE_LAYERS;
|
const layers = env.VK_INSTANCE_LAYERS;
|
||||||
if (typeof layers === 'string' && layers.trim().length > 0) {
|
if (typeof layers === 'string' && layers.trim().length > 0) {
|
||||||
const filtered = layers
|
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.statsCleanupVocab, false);
|
||||||
assert.equal(parsed.statsCleanupLifetime, true);
|
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;
|
statsCleanupLifetime?: boolean;
|
||||||
dictionaryTarget?: string;
|
dictionaryTarget?: string;
|
||||||
doctor: boolean;
|
doctor: boolean;
|
||||||
|
doctorRefreshKnownWords: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
configShow: boolean;
|
configShow: boolean;
|
||||||
mpvIdle: 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) {
|
constructor(url: string) {
|
||||||
const httpAgent = new http.Agent({
|
const httpAgent = new http.Agent({
|
||||||
keepAlive: true,
|
keepAlive: false,
|
||||||
keepAliveMsecs: 1000,
|
keepAliveMsecs: 1000,
|
||||||
maxSockets: 5,
|
maxSockets: 5,
|
||||||
maxFreeSockets: 2,
|
maxFreeSockets: 2,
|
||||||
@@ -51,7 +51,7 @@ export class AnkiConnectClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
keepAlive: true,
|
keepAlive: false,
|
||||||
keepAliveMsecs: 1000,
|
keepAliveMsecs: 1000,
|
||||||
maxSockets: 5,
|
maxSockets: 5,
|
||||||
maxFreeSockets: 2,
|
maxFreeSockets: 2,
|
||||||
@@ -106,7 +106,7 @@ export class AnkiConnectClient {
|
|||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs);
|
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);
|
await this.sleep(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,37 @@ import { KnownWordCacheManager } from './known-word-cache';
|
|||||||
|
|
||||||
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
||||||
manager: KnownWordCacheManager;
|
manager: KnownWordCacheManager;
|
||||||
|
calls: {
|
||||||
|
findNotes: number;
|
||||||
|
notesInfo: number;
|
||||||
|
};
|
||||||
|
statePath: string;
|
||||||
|
clientState: {
|
||||||
|
findNotesResult: number[];
|
||||||
|
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||||
|
};
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
} {
|
} {
|
||||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
|
||||||
const statePath = path.join(stateDir, 'known-words-cache.json');
|
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({
|
const manager = new KnownWordCacheManager({
|
||||||
client: {
|
client: {
|
||||||
findNotes: async () => [],
|
findNotes: async () => {
|
||||||
notesInfo: async () => [],
|
calls.findNotes += 1;
|
||||||
|
return clientState.findNotesResult;
|
||||||
|
},
|
||||||
|
notesInfo: async (noteIds) => {
|
||||||
|
calls.notesInfo += 1;
|
||||||
|
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
getConfig: () => config,
|
getConfig: () => config,
|
||||||
knownWordCacheStatePath: statePath,
|
knownWordCacheStatePath: statePath,
|
||||||
@@ -25,12 +48,49 @@ function createKnownWordCacheHarness(config: AnkiConnectConfig): {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
manager,
|
manager,
|
||||||
|
calls,
|
||||||
|
statePath,
|
||||||
|
clientState,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
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', () => {
|
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
deck: 'Mining',
|
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', () => {
|
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
@@ -110,3 +234,27 @@ test('KnownWordCacheManager invalidates persisted cache when per-deck fields cha
|
|||||||
cleanup();
|
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 }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KnownWordCacheState {
|
interface KnownWordCacheStateV1 {
|
||||||
readonly version: 1;
|
readonly version: 1;
|
||||||
readonly refreshedAtMs: number;
|
readonly refreshedAtMs: number;
|
||||||
readonly scope: string;
|
readonly scope: string;
|
||||||
readonly words: 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 {
|
interface KnownWordCacheClient {
|
||||||
findNotes: (
|
findNotes: (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -92,7 +102,10 @@ export class KnownWordCacheManager {
|
|||||||
private knownWordsLastRefreshedAtMs = 0;
|
private knownWordsLastRefreshedAtMs = 0;
|
||||||
private knownWordsStateKey = '';
|
private knownWordsStateKey = '';
|
||||||
private knownWords: Set<string> = new Set();
|
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 knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isRefreshingKnownWords = false;
|
private isRefreshingKnownWords = false;
|
||||||
private readonly statePath: string;
|
private readonly statePath: string;
|
||||||
|
|
||||||
@@ -133,14 +146,14 @@ export class KnownWordCacheManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.loadKnownWordCacheState();
|
this.loadKnownWordCacheState();
|
||||||
void this.refreshKnownWords();
|
this.scheduleKnownWordRefreshLifecycle();
|
||||||
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
|
|
||||||
this.knownWordsRefreshTimer = setInterval(() => {
|
|
||||||
void this.refreshKnownWords();
|
|
||||||
}, refreshIntervalMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopLifecycle(): void {
|
stopLifecycle(): void {
|
||||||
|
if (this.knownWordsRefreshTimeout) {
|
||||||
|
clearTimeout(this.knownWordsRefreshTimeout);
|
||||||
|
this.knownWordsRefreshTimeout = null;
|
||||||
|
}
|
||||||
if (this.knownWordsRefreshTimer) {
|
if (this.knownWordsRefreshTimer) {
|
||||||
clearInterval(this.knownWordsRefreshTimer);
|
clearInterval(this.knownWordsRefreshTimer);
|
||||||
this.knownWordsRefreshTimer = null;
|
this.knownWordsRefreshTimer = null;
|
||||||
@@ -148,7 +161,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
||||||
if (!this.isKnownWordCacheEnabled()) {
|
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,32 +173,26 @@ export class KnownWordCacheManager {
|
|||||||
this.knownWordsStateKey = currentStateKey;
|
this.knownWordsStateKey = currentStateKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
let addedCount = 0;
|
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo);
|
||||||
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||||
const normalized = this.normalizeKnownWordForLookup(rawWord);
|
if (!changed) {
|
||||||
if (!normalized || this.knownWords.has(normalized)) {
|
return;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.knownWords.add(normalized);
|
|
||||||
addedCount += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addedCount > 0) {
|
|
||||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
}
|
}
|
||||||
this.persistKnownWordCacheState();
|
this.persistKnownWordCacheState();
|
||||||
log.info(
|
log.info(
|
||||||
'Known-word cache updated in-session',
|
'Known-word cache updated in-session',
|
||||||
`added=${addedCount}`,
|
`noteId=${noteInfo.noteId}`,
|
||||||
|
`wordCount=${nextWords.length}`,
|
||||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
clearKnownWordCacheState(): void {
|
clearKnownWordCacheState(): void {
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.statePath)) {
|
if (fs.existsSync(this.statePath)) {
|
||||||
@@ -218,33 +225,38 @@ export class KnownWordCacheManager {
|
|||||||
maxRetries: 0,
|
maxRetries: 0,
|
||||||
})) as number[];
|
})) as number[];
|
||||||
|
|
||||||
const nextKnownWords = new Set<string>();
|
const currentNoteIds = Array.from(
|
||||||
if (noteIds.length > 0) {
|
new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0)),
|
||||||
const chunkSize = 50;
|
).sort((a, b) => a - b);
|
||||||
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[];
|
|
||||||
|
|
||||||
for (const noteInfo of notesInfo) {
|
if (this.noteWordsById.size === 0) {
|
||||||
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) {
|
await this.rebuildFromCurrentNotes(currentNoteIds);
|
||||||
const normalized = this.normalizeKnownWordForLookup(word);
|
} else {
|
||||||
if (normalized) {
|
const currentNoteIdSet = new Set(currentNoteIds);
|
||||||
nextKnownWords.add(normalized);
|
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.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
this.persistKnownWordCacheState();
|
this.persistKnownWordCacheState();
|
||||||
log.info(
|
log.info(
|
||||||
'Known-word cache refreshed',
|
'Known-word cache refreshed',
|
||||||
`noteCount=${noteIds.length}`,
|
`noteCount=${currentNoteIds.length}`,
|
||||||
`wordCount=${nextKnownWords.size}`,
|
`wordCount=${this.knownWords.size}`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
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;
|
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldAddMinedWordsImmediately(): boolean {
|
||||||
|
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||||
|
}
|
||||||
|
|
||||||
private getKnownWordRefreshIntervalMs(): number {
|
private getKnownWordRefreshIntervalMs(): number {
|
||||||
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
|
return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
|
||||||
}
|
}
|
||||||
@@ -322,64 +338,193 @@ export class KnownWordCacheManager {
|
|||||||
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
|
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 {
|
private loadKnownWordCacheState(): void {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(this.statePath)) {
|
if (!fs.existsSync(this.statePath)) {
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = fs.readFileSync(this.statePath, 'utf-8');
|
const raw = fs.readFileSync(this.statePath, 'utf-8');
|
||||||
if (!raw.trim()) {
|
if (!raw.trim()) {
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!this.isKnownWordCacheStateValid(parsed)) {
|
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
|
if (parsed.scope !== this.getKnownWordCacheStateKey()) {
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
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) {
|
for (const value of parsed.words) {
|
||||||
const normalized = this.normalizeKnownWordForLookup(value);
|
const normalized = this.normalizeKnownWordForLookup(value);
|
||||||
if (normalized) {
|
if (!normalized) {
|
||||||
nextKnownWords.add(normalized);
|
continue;
|
||||||
|
}
|
||||||
|
this.knownWords.add(normalized);
|
||||||
|
this.wordReferenceCounts.set(normalized, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knownWords = nextKnownWords;
|
|
||||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||||
this.knownWordsStateKey = parsed.scope;
|
this.knownWordsStateKey = parsed.scope;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||||
this.knownWords = new Set();
|
this.clearInMemoryState();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistKnownWordCacheState(): void {
|
private persistKnownWordCacheState(): void {
|
||||||
try {
|
try {
|
||||||
const state: KnownWordCacheState = {
|
const notes: Record<string, string[]> = {};
|
||||||
version: 1,
|
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||||
|
if (words.length > 0) {
|
||||||
|
notes[String(noteId)] = words;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: KnownWordCacheStateV2 = {
|
||||||
|
version: 2,
|
||||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||||
scope: this.knownWordsStateKey,
|
scope: this.knownWordsStateKey,
|
||||||
words: Array.from(this.knownWords),
|
words: Array.from(this.knownWords),
|
||||||
|
notes,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -389,18 +534,35 @@ export class KnownWordCacheManager {
|
|||||||
|
|
||||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
const candidate = value as Partial<KnownWordCacheState>;
|
const candidate = value as Record<string, unknown>;
|
||||||
if (candidate.version !== 1) return false;
|
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||||
if (typeof candidate.scope !== 'string') return false;
|
if (typeof candidate.scope !== 'string') return false;
|
||||||
if (!Array.isArray(candidate.words)) 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;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
private extractNormalizedKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] {
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
const configuredFields = this.getConfiguredFields();
|
const configuredFields = this.getConfiguredFields();
|
||||||
for (const preferredField of configuredFields) {
|
for (const preferredField of configuredFields) {
|
||||||
@@ -410,12 +572,12 @@ export class KnownWordCacheManager {
|
|||||||
const raw = noteInfo.fields[fieldName]?.value;
|
const raw = noteInfo.fields[fieldName]?.value;
|
||||||
if (!raw) continue;
|
if (!raw) continue;
|
||||||
|
|
||||||
const extracted = this.normalizeRawKnownWordValue(raw);
|
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||||
if (extracted) {
|
if (normalized) {
|
||||||
words.push(extracted);
|
words.push(normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return words;
|
return normalizeKnownWordList(words);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRawKnownWordValue(value: string): string {
|
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 {
|
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
hasExplicitCommand,
|
hasExplicitCommand,
|
||||||
|
isHeadlessInitialCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
shouldRunSettingsOnlyStartup,
|
shouldRunSettingsOnlyStartup,
|
||||||
shouldStartApp,
|
shouldStartApp,
|
||||||
@@ -101,7 +102,8 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
const refreshKnownWords = parseArgs(['--refresh-known-words']);
|
const refreshKnownWords = parseArgs(['--refresh-known-words']);
|
||||||
assert.equal(refreshKnownWords.help, false);
|
assert.equal(refreshKnownWords.help, false);
|
||||||
assert.equal(hasExplicitCommand(refreshKnownWords), true);
|
assert.equal(hasExplicitCommand(refreshKnownWords), true);
|
||||||
assert.equal(shouldStartApp(refreshKnownWords), false);
|
assert.equal(shouldStartApp(refreshKnownWords), true);
|
||||||
|
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
|
||||||
|
|
||||||
const settings = parseArgs(['--settings']);
|
const settings = parseArgs(['--settings']);
|
||||||
assert.equal(settings.settings, true);
|
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 {
|
export function shouldStartApp(args: CliArgs): boolean {
|
||||||
if (args.stop && !args.start) return false;
|
if (args.stop && !args.start) return false;
|
||||||
if (
|
if (
|
||||||
@@ -391,6 +395,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.mineSentence ||
|
args.mineSentence ||
|
||||||
args.mineSentenceMultiple ||
|
args.mineSentenceMultiple ||
|
||||||
args.updateLastCardFromClipboard ||
|
args.updateLastCardFromClipboard ||
|
||||||
|
args.refreshKnownWords ||
|
||||||
args.toggleSecondarySub ||
|
args.toggleSecondarySub ||
|
||||||
args.triggerFieldGrouping ||
|
args.triggerFieldGrouping ||
|
||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('printHelp includes configured texthooker port', () => {
|
|||||||
assert.match(output, /default: 7777/);
|
assert.match(output, /default: 7777/);
|
||||||
assert.match(output, /--launch-mpv/);
|
assert.match(output, /--launch-mpv/);
|
||||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
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, /--setup\s+Open first-run setup window/);
|
||||||
assert.match(output, /--anilist-status/);
|
assert.match(output, /--anilist-status/);
|
||||||
assert.match(output, /--anilist-retry-queue/);
|
assert.match(output, /--anilist-retry-queue/);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ ${B}Mining${R}
|
|||||||
--trigger-field-grouping Run Kiku field grouping
|
--trigger-field-grouping Run Kiku field grouping
|
||||||
--trigger-subsync Run subtitle sync
|
--trigger-subsync Run subtitle sync
|
||||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||||
--refresh-known-words Refresh known words cache
|
|
||||||
--open-runtime-options Open runtime options palette
|
--open-runtime-options Open runtime options palette
|
||||||
|
|
||||||
${B}AniList${R}
|
${B}AniList${R}
|
||||||
|
|||||||
@@ -1435,7 +1435,8 @@ test('validates ankiConnect knownWords behavior values', () => {
|
|||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": "yes",
|
"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.highlightEnabled'));
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.refreshMinutes'));
|
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', () => {
|
test('accepts valid ankiConnect knownWords behavior values', () => {
|
||||||
@@ -1466,7 +1474,8 @@ test('accepts valid ankiConnect knownWords behavior values', () => {
|
|||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": true,
|
"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.highlightEnabled, true);
|
||||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
|
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 120);
|
||||||
|
assert.equal(config.ankiConnect.knownWords.addMinedWordsImmediately, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('validates ankiConnect n+1 minimum sentence word count', () => {
|
test('validates ankiConnect n+1 minimum sentence word count', () => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
knownWords: {
|
knownWords: {
|
||||||
highlightEnabled: false,
|
highlightEnabled: false,
|
||||||
refreshMinutes: 1440,
|
refreshMinutes: 1440,
|
||||||
|
addMinedWordsImmediately: true,
|
||||||
matchMode: 'headword',
|
matchMode: 'headword',
|
||||||
decks: {},
|
decks: {},
|
||||||
color: '#a6da95',
|
color: '#a6da95',
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
|
defaultValue: defaultConfig.ankiConnect.knownWords.refreshMinutes,
|
||||||
description: 'Minutes between known-word cache refreshes.',
|
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',
|
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
kind: 'number',
|
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', () => {
|
test('converts legacy knownWords.decks array to object with default fields', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
knownWords: { decks: ['Core Deck'] },
|
knownWords: { decks: ['Core Deck'] },
|
||||||
|
|||||||
@@ -771,6 +771,24 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
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 nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||||
const hasValidNPlusOneMinSentenceWords =
|
const hasValidNPlusOneMinSentenceWords =
|
||||||
nPlusOneMinSentenceWords !== undefined &&
|
nPlusOneMinSentenceWords !== undefined &&
|
||||||
|
|||||||
@@ -539,8 +539,21 @@ test('handleCliCommand runs refresh-known-words command', () => {
|
|||||||
assert.ok(calls.includes('refreshKnownWords'));
|
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 () => {
|
test('handleCliCommand reports async refresh-known-words errors to OSD', async () => {
|
||||||
const { deps, calls, osd } = createDeps({
|
const { deps, calls, osd } = createDeps({
|
||||||
|
hasMainWindow: () => false,
|
||||||
refreshKnownWords: async () => {
|
refreshKnownWords: async () => {
|
||||||
throw new Error('refresh boom');
|
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(calls.some((value) => value.startsWith('error:refreshKnownWords failed:')));
|
||||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
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',
|
'Update failed',
|
||||||
);
|
);
|
||||||
} else if (args.refreshKnownWords) {
|
} else if (args.refreshKnownWords) {
|
||||||
runAsyncWithOsd(
|
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||||
() => deps.refreshKnownWords(),
|
deps
|
||||||
deps,
|
.refreshKnownWords()
|
||||||
'refreshKnownWords',
|
.catch((err) => {
|
||||||
'Refresh known words failed',
|
deps.error('refreshKnownWords failed:', err);
|
||||||
);
|
deps.showMpvOsd(`Refresh known words failed: ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (shouldStopAfterRun) {
|
||||||
|
deps.stopApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (args.toggleSecondarySub) {
|
} else if (args.toggleSecondarySub) {
|
||||||
deps.cycleSecondarySubMode();
|
deps.cycleSecondarySubMode();
|
||||||
} else if (args.triggerFieldGrouping) {
|
} else if (args.triggerFieldGrouping) {
|
||||||
|
|||||||
@@ -109,6 +109,60 @@ test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled
|
|||||||
assert.equal(setIntegrationCalls, 1);
|
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', () => {
|
test('initializeOverlayRuntime merges shared ai config with Anki overrides', () => {
|
||||||
initializeOverlayRuntime({
|
initializeOverlayRuntime({
|
||||||
backendOverride: null,
|
backendOverride: null,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function initializeOverlayRuntime(options: {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration?: () => boolean;
|
||||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||||
}): void {
|
}): void {
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
@@ -135,7 +136,9 @@ export function initializeOverlayRuntime(options: {
|
|||||||
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
createFieldGroupingCallback: options.createFieldGroupingCallback,
|
||||||
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
|
||||||
});
|
});
|
||||||
|
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||||
integration.start();
|
integration.start();
|
||||||
|
}
|
||||||
options.setAnkiIntegration(integration);
|
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']);
|
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;
|
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||||
initializeOverlayRuntime: () => void;
|
initializeOverlayRuntime: () => void;
|
||||||
|
runHeadlessInitialCommand?: () => Promise<void>;
|
||||||
handleInitialArgs: () => void;
|
handleInitialArgs: () => void;
|
||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||||
shouldUseMinimalStartup?: () => boolean;
|
shouldUseMinimalStartup?: () => boolean;
|
||||||
shouldSkipHeavyStartup?: () => boolean;
|
shouldSkipHeavyStartup?: () => boolean;
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,20 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
const now = deps.now ?? (() => Date.now());
|
const now = deps.now ?? (() => Date.now());
|
||||||
const startupStartedAtMs = now();
|
const startupStartedAtMs = now();
|
||||||
deps.ensureDefaultConfigBootstrap();
|
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?.()) {
|
if (deps.shouldUseMinimalStartup?.()) {
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
deps.handleInitialArgs();
|
deps.handleInitialArgs();
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
|||||||
version?: number;
|
version?: number;
|
||||||
words?: string[];
|
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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
80
src/main.ts
80
src/main.ts
@@ -31,6 +31,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||||
|
import { mergeAiConfig } from './ai/config';
|
||||||
|
|
||||||
function getPasswordStoreArg(argv: string[]): string | null {
|
function getPasswordStoreArg(argv: string[]): string | null {
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
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 { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||||
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
||||||
import { resolveDefaultLogFilePath } from './logger';
|
import { resolveDefaultLogFilePath } from './logger';
|
||||||
|
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||||
import {
|
import {
|
||||||
commandNeedsOverlayRuntime,
|
commandNeedsOverlayRuntime,
|
||||||
|
isHeadlessInitialCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
shouldRunSettingsOnlyStartup,
|
shouldRunSettingsOnlyStartup,
|
||||||
shouldStartApp,
|
shouldStartApp,
|
||||||
@@ -2837,6 +2840,50 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
|
|||||||
logError: (message, error) => logger.error(message, error),
|
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({
|
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||||
reloadConfigMainDeps: {
|
reloadConfigMainDeps: {
|
||||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||||
@@ -2984,7 +3031,10 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
|||||||
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
: configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||||
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
|
||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
|
runHeadlessInitialCommand: () => runHeadlessInitialCommand(),
|
||||||
handleInitialArgs: () => handleInitialArgs(),
|
handleInitialArgs: () => handleInitialArgs(),
|
||||||
|
shouldRunHeadlessInitialCommand: () =>
|
||||||
|
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
shouldUseMinimalStartup: () =>
|
shouldUseMinimalStartup: () =>
|
||||||
Boolean(
|
Boolean(
|
||||||
appState.initialArgs?.stats &&
|
appState.initialArgs?.stats &&
|
||||||
@@ -3096,6 +3146,7 @@ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
|||||||
getInitialArgs: () => appState.initialArgs,
|
getInitialArgs: () => appState.initialArgs,
|
||||||
isBackgroundMode: () => appState.backgroundMode,
|
isBackgroundMode: () => appState.backgroundMode,
|
||||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
||||||
|
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
||||||
ensureTray: () => ensureTray(),
|
ensureTray: () => ensureTray(),
|
||||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||||
@@ -4139,8 +4190,24 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
overlayShortcutsRuntime: {
|
overlayShortcutsRuntime: {
|
||||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||||
},
|
},
|
||||||
createMainWindow: () => createMainWindow(),
|
createMainWindow: () => {
|
||||||
registerGlobalShortcuts: () => registerGlobalShortcuts(),
|
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: WindowGeometry) =>
|
||||||
updateVisibleOverlayBounds(geometry),
|
updateVisibleOverlayBounds(geometry),
|
||||||
getOverlayWindows: () => getOverlayWindows(),
|
getOverlayWindows: () => getOverlayWindows(),
|
||||||
@@ -4148,6 +4215,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
|
shouldStartAnkiIntegration: () =>
|
||||||
|
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||||
},
|
},
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
initializeOverlayRuntimeBootstrapDeps: {
|
||||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||||
@@ -4155,7 +4224,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
setOverlayRuntimeInitialized: (initialized) => {
|
setOverlayRuntimeInitialized: (initialized) => {
|
||||||
appState.overlayRuntimeInitialized = initialized;
|
appState.overlayRuntimeInitialized = initialized;
|
||||||
},
|
},
|
||||||
startBackgroundWarmups: () => startBackgroundWarmups(),
|
startBackgroundWarmups: () => {
|
||||||
|
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startBackgroundWarmups();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
|||||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||||
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
setVisibleOverlayVisible: AppReadyRuntimeDeps['setVisibleOverlayVisible'];
|
||||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||||
|
runHeadlessInitialCommand?: AppReadyRuntimeDeps['runHeadlessInitialCommand'];
|
||||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||||
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||||
now?: AppReadyRuntimeDeps['now'];
|
now?: AppReadyRuntimeDeps['now'];
|
||||||
|
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
|
||||||
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
|
||||||
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
|
||||||
}
|
}
|
||||||
@@ -115,10 +117,12 @@ export function createAppReadyRuntimeDeps(
|
|||||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||||
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
setVisibleOverlayVisible: params.setVisibleOverlayVisible,
|
||||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||||
|
runHeadlessInitialCommand: params.runHeadlessInitialCommand,
|
||||||
handleInitialArgs: params.handleInitialArgs,
|
handleInitialArgs: params.handleInitialArgs,
|
||||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||||
logDebug: params.logDebug,
|
logDebug: params.logDebug,
|
||||||
now: params.now,
|
now: params.now,
|
||||||
|
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
|
||||||
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
|
||||||
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
|||||||
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
shouldAutoInitializeOverlayRuntimeFromConfig: deps.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||||
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
initializeOverlayRuntime: deps.initializeOverlayRuntime,
|
||||||
|
runHeadlessInitialCommand: deps.runHeadlessInitialCommand,
|
||||||
handleInitialArgs: deps.handleInitialArgs,
|
handleInitialArgs: deps.handleInitialArgs,
|
||||||
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
onCriticalConfigErrors: deps.onCriticalConfigErrors,
|
||||||
logDebug: deps.logDebug,
|
logDebug: deps.logDebug,
|
||||||
now: deps.now,
|
now: deps.now,
|
||||||
|
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
|
||||||
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
|
||||||
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ test('initial args handler no-ops without initial args', () => {
|
|||||||
getInitialArgs: () => null,
|
getInitialArgs: () => null,
|
||||||
isBackgroundMode: () => false,
|
isBackgroundMode: () => false,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => {},
|
ensureTray: () => {},
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
@@ -28,6 +29,7 @@ test('initial args handler ensures tray in background mode', () => {
|
|||||||
getInitialArgs: () => ({ start: true }) as never,
|
getInitialArgs: () => ({ start: true }) as never,
|
||||||
isBackgroundMode: () => true,
|
isBackgroundMode: () => true,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => {
|
ensureTray: () => {
|
||||||
ensuredTray = true;
|
ensuredTray = true;
|
||||||
},
|
},
|
||||||
@@ -49,6 +51,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
|||||||
getInitialArgs: () => ({ start: true }) as never,
|
getInitialArgs: () => ({ start: true }) as never,
|
||||||
isBackgroundMode: () => false,
|
isBackgroundMode: () => false,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => {},
|
ensureTray: () => {},
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => true,
|
hasImmersionTracker: () => true,
|
||||||
@@ -75,6 +78,7 @@ test('initial args handler forwards args to cli handler', () => {
|
|||||||
getInitialArgs: () => ({ start: true }) as never,
|
getInitialArgs: () => ({ start: true }) as never,
|
||||||
isBackgroundMode: () => false,
|
isBackgroundMode: () => false,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => {},
|
ensureTray: () => {},
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => false,
|
hasImmersionTracker: () => false,
|
||||||
@@ -95,6 +99,7 @@ test('initial args handler can ensure tray outside background mode when requeste
|
|||||||
getInitialArgs: () => ({ start: true }) as never,
|
getInitialArgs: () => ({ start: true }) as never,
|
||||||
isBackgroundMode: () => false,
|
isBackgroundMode: () => false,
|
||||||
shouldEnsureTrayOnStartup: () => true,
|
shouldEnsureTrayOnStartup: () => true,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => {
|
ensureTray: () => {
|
||||||
ensuredTray = true;
|
ensuredTray = true;
|
||||||
},
|
},
|
||||||
@@ -108,3 +113,31 @@ test('initial args handler can ensure tray outside background mode when requeste
|
|||||||
handleInitialArgs();
|
handleInitialArgs();
|
||||||
assert.equal(ensuredTray, true);
|
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;
|
getInitialArgs: () => CliArgs | null;
|
||||||
isBackgroundMode: () => boolean;
|
isBackgroundMode: () => boolean;
|
||||||
shouldEnsureTrayOnStartup: () => boolean;
|
shouldEnsureTrayOnStartup: () => boolean;
|
||||||
|
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||||
ensureTray: () => void;
|
ensureTray: () => void;
|
||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
hasImmersionTracker: () => boolean;
|
hasImmersionTracker: () => boolean;
|
||||||
@@ -19,13 +20,15 @@ export function createHandleInitialArgsHandler(deps: {
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
const initialArgs = deps.getInitialArgs();
|
const initialArgs = deps.getInitialArgs();
|
||||||
if (!initialArgs) return;
|
if (!initialArgs) return;
|
||||||
|
const runHeadless = deps.shouldRunHeadlessInitialCommand(initialArgs);
|
||||||
|
|
||||||
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
|
if (!runHeadless && (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup())) {
|
||||||
deps.ensureTray();
|
deps.ensureTray();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mpvClient = deps.getMpvClient();
|
const mpvClient = deps.getMpvClient();
|
||||||
if (
|
if (
|
||||||
|
!runHeadless &&
|
||||||
!deps.isTexthookerOnlyMode() &&
|
!deps.isTexthookerOnlyMode() &&
|
||||||
!initialArgs.stats &&
|
!initialArgs.stats &&
|
||||||
deps.hasImmersionTracker() &&
|
deps.hasImmersionTracker() &&
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
|||||||
getInitialArgs: () => args,
|
getInitialArgs: () => args,
|
||||||
isBackgroundMode: () => true,
|
isBackgroundMode: () => true,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => calls.push('ensure-tray'),
|
ensureTray: () => calls.push('ensure-tray'),
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => true,
|
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.getInitialArgs(), args);
|
||||||
assert.equal(deps.isBackgroundMode(), true);
|
assert.equal(deps.isBackgroundMode(), true);
|
||||||
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
||||||
|
assert.equal(deps.shouldRunHeadlessInitialCommand(args), false);
|
||||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||||
assert.equal(deps.hasImmersionTracker(), true);
|
assert.equal(deps.hasImmersionTracker(), true);
|
||||||
assert.equal(deps.getMpvClient(), mpvClient);
|
assert.equal(deps.getMpvClient(), mpvClient);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
|||||||
getInitialArgs: () => CliArgs | null;
|
getInitialArgs: () => CliArgs | null;
|
||||||
isBackgroundMode: () => boolean;
|
isBackgroundMode: () => boolean;
|
||||||
shouldEnsureTrayOnStartup: () => boolean;
|
shouldEnsureTrayOnStartup: () => boolean;
|
||||||
|
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||||
ensureTray: () => void;
|
ensureTray: () => void;
|
||||||
isTexthookerOnlyMode: () => boolean;
|
isTexthookerOnlyMode: () => boolean;
|
||||||
hasImmersionTracker: () => boolean;
|
hasImmersionTracker: () => boolean;
|
||||||
@@ -15,6 +16,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
|||||||
getInitialArgs: () => deps.getInitialArgs(),
|
getInitialArgs: () => deps.getInitialArgs(),
|
||||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||||
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
||||||
|
shouldRunHeadlessInitialCommand: (args: CliArgs) => deps.shouldRunHeadlessInitialCommand(args),
|
||||||
ensureTray: () => deps.ensureTray(),
|
ensureTray: () => deps.ensureTray(),
|
||||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
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,
|
getInitialArgs: () => ({ start: true }) as never,
|
||||||
isBackgroundMode: () => true,
|
isBackgroundMode: () => true,
|
||||||
shouldEnsureTrayOnStartup: () => false,
|
shouldEnsureTrayOnStartup: () => false,
|
||||||
|
shouldRunHeadlessInitialCommand: () => false,
|
||||||
ensureTray: () => calls.push('tray'),
|
ensureTray: () => calls.push('tray'),
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => true,
|
hasImmersionTracker: () => true,
|
||||||
@@ -35,6 +36,30 @@ test('initial args runtime handler skips mpv auto-connect for stats mode', () =>
|
|||||||
getInitialArgs: () => ({ stats: true }) as never,
|
getInitialArgs: () => ({ stats: true }) as never,
|
||||||
isBackgroundMode: () => false,
|
isBackgroundMode: () => false,
|
||||||
shouldEnsureTrayOnStartup: () => 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'),
|
ensureTray: () => calls.push('tray'),
|
||||||
isTexthookerOnlyMode: () => false,
|
isTexthookerOnlyMode: () => false,
|
||||||
hasImmersionTracker: () => true,
|
hasImmersionTracker: () => true,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
|
|||||||
cancelled: true,
|
cancelled: true,
|
||||||
}) as KikuFieldGroupingChoice,
|
}) as KikuFieldGroupingChoice,
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||||
|
shouldStartAnkiIntegration: () => true,
|
||||||
},
|
},
|
||||||
initializeOverlayRuntimeBootstrapDeps: {
|
initializeOverlayRuntimeBootstrapDeps: {
|
||||||
isOverlayRuntimeInitialized: () => initialized,
|
isOverlayRuntimeInitialized: () => initialized,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type InitializeOverlayRuntimeCore = (options: {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
|||||||
cancelled: true,
|
cancelled: true,
|
||||||
}),
|
}),
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
shouldStartAnkiIntegration: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deps = build();
|
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.isVisibleOverlayVisible(), true);
|
||||||
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
|
assert.equal(deps.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||||
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
assert.equal(deps.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||||
|
assert.equal(deps.shouldStartAnkiIntegration(), false);
|
||||||
|
|
||||||
deps.createMainWindow();
|
deps.createMainWindow();
|
||||||
deps.registerGlobalShortcuts();
|
deps.registerGlobalShortcuts();
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
height: number;
|
height: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
|
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
|
||||||
|
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||||
getBackendOverride: () => deps.appState.backendOverride,
|
getBackendOverride: () => deps.appState.backendOverride,
|
||||||
@@ -56,6 +58,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
setWindowTracker: (tracker) => {
|
setWindowTracker: (tracker) => {
|
||||||
deps.appState.windowTracker = tracker;
|
deps.appState.windowTracker = tracker;
|
||||||
},
|
},
|
||||||
|
createWindowTracker: deps.createWindowTracker,
|
||||||
getResolvedConfig: () => deps.getResolvedConfig(),
|
getResolvedConfig: () => deps.getResolvedConfig(),
|
||||||
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
|
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
|
||||||
getMpvClient: () => deps.appState.mpvClient,
|
getMpvClient: () => deps.appState.mpvClient,
|
||||||
@@ -67,5 +70,6 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
|||||||
showDesktopNotification: deps.showDesktopNotification,
|
showDesktopNotification: deps.showDesktopNotification,
|
||||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||||
|
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
}),
|
}),
|
||||||
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
|
||||||
|
shouldStartAnkiIntegration: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = buildOptions();
|
const options = buildOptions();
|
||||||
@@ -35,6 +36,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
|||||||
assert.equal(options.isVisibleOverlayVisible(), true);
|
assert.equal(options.isVisibleOverlayVisible(), true);
|
||||||
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
|
assert.equal(options.getMpvSocketPath(), '/tmp/mpv.sock');
|
||||||
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
assert.equal(options.getKnownWordCacheStatePath(), '/tmp/known-words-cache.json');
|
||||||
|
assert.equal(options.shouldStartAnkiIntegration(), true);
|
||||||
options.createMainWindow();
|
options.createMainWindow();
|
||||||
options.registerGlobalShortcuts();
|
options.registerGlobalShortcuts();
|
||||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ type OverlayRuntimeOptions = {
|
|||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
|
createWindowTracker?: (
|
||||||
|
override?: string | null,
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
) => BaseWindowTracker | null;
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||||
getSubtitleTimingTracker: () => unknown | null;
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||||
@@ -30,6 +34,7 @@ type OverlayRuntimeOptions = {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||||
@@ -42,6 +47,10 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
getOverlayWindows: () => BrowserWindow[];
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
syncOverlayShortcuts: () => void;
|
syncOverlayShortcuts: () => void;
|
||||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
|
createWindowTracker?: (
|
||||||
|
override?: string | null,
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
) => BaseWindowTracker | null;
|
||||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||||
getSubtitleTimingTracker: () => unknown | null;
|
getSubtitleTimingTracker: () => unknown | null;
|
||||||
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
|
||||||
@@ -55,6 +64,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
data: KikuFieldGroupingRequestData,
|
data: KikuFieldGroupingRequestData,
|
||||||
) => Promise<KikuFieldGroupingChoice>;
|
) => Promise<KikuFieldGroupingChoice>;
|
||||||
getKnownWordCacheStatePath: () => string;
|
getKnownWordCacheStatePath: () => string;
|
||||||
|
shouldStartAnkiIntegration: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
return (): OverlayRuntimeOptions => ({
|
return (): OverlayRuntimeOptions => ({
|
||||||
backendOverride: deps.getBackendOverride(),
|
backendOverride: deps.getBackendOverride(),
|
||||||
@@ -66,6 +76,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
getOverlayWindows: deps.getOverlayWindows,
|
getOverlayWindows: deps.getOverlayWindows,
|
||||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||||
setWindowTracker: deps.setWindowTracker,
|
setWindowTracker: deps.setWindowTracker,
|
||||||
|
createWindowTracker: deps.createWindowTracker,
|
||||||
getResolvedConfig: deps.getResolvedConfig,
|
getResolvedConfig: deps.getResolvedConfig,
|
||||||
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
|
getSubtitleTimingTracker: deps.getSubtitleTimingTracker,
|
||||||
getMpvClient: deps.getMpvClient,
|
getMpvClient: deps.getMpvClient,
|
||||||
@@ -75,5 +86,6 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
|||||||
showDesktopNotification: deps.showDesktopNotification,
|
showDesktopNotification: deps.showDesktopNotification,
|
||||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||||
|
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export interface AnkiConnectConfig {
|
|||||||
knownWords?: {
|
knownWords?: {
|
||||||
highlightEnabled?: boolean;
|
highlightEnabled?: boolean;
|
||||||
refreshMinutes?: number;
|
refreshMinutes?: number;
|
||||||
|
addMinedWordsImmediately?: boolean;
|
||||||
matchMode?: NPlusOneMatchMode;
|
matchMode?: NPlusOneMatchMode;
|
||||||
decks?: Record<string, string[]>;
|
decks?: Record<string, string[]>;
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -754,6 +755,7 @@ export interface ResolvedConfig {
|
|||||||
knownWords: {
|
knownWords: {
|
||||||
highlightEnabled: boolean;
|
highlightEnabled: boolean;
|
||||||
refreshMinutes: number;
|
refreshMinutes: number;
|
||||||
|
addMinedWordsImmediately: boolean;
|
||||||
matchMode: NPlusOneMatchMode;
|
matchMode: NPlusOneMatchMode;
|
||||||
decks: Record<string, string[]>;
|
decks: Record<string, string[]>;
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user