feat: add manual known-word cache refresh path

- Add CLI command flag  with non-GUI dispatch flow and OSD error handling.

- Add runtime integration call and IPC hook so manual refresh works from command runner without app startup.

- Add public AnkiIntegration manual refresh API with force refresh semantics and guard reuse.

- Preserve default n+1 behavior by fixing config validation for malformed  values and adding tests.
This commit is contained in:
2026-02-15 00:03:38 -08:00
parent fb20e1ca25
commit a1f196ee52
17 changed files with 315 additions and 23 deletions

View File

@@ -16,11 +16,23 @@ SubMiner uses a service-oriented Electron architecture with a composition-orient
```text
src/
main.ts # Composition root — lifecycle wiring and state ownership
main.ts # Entry point — delegates to src/main/ composition modules
preload.ts # Electron preload bridge
types.ts # Shared type definitions
main/ # Composition root modules (extracted from main.ts)
app-lifecycle.ts # Electron lifecycle event registration
cli-runtime.ts # CLI command handling and dispatch
dependencies.ts # Shared dependency builders for IPC/runtime
ipc-mpv-command.ts # MPV command composition helpers
ipc-runtime.ts # IPC channel registration and handlers
overlay-runtime.ts # Overlay window/modal selection and state
overlay-shortcuts-runtime.ts # Overlay keyboard shortcut handling
startup.ts # Startup bootstrap flow (argv/env processing)
startup-lifecycle.ts # App-ready initialization sequence
state.ts # Application runtime state container
subsync-runtime.ts # Subsync command orchestration
core/
services/ # ~55 focused service modules (see below)
services/ # ~60 focused service modules (see below)
utils/ # Pure helpers and coercion/config utilities
cli/ # CLI parsing and help output
config/ # Config schema, defaults, validation, template generation
@@ -36,10 +48,10 @@ src/
### Service Layer (`src/core/services/`)
- **Startup** — `startup-service`, `app-lifecycle-service`
- **Overlay** — `overlay-manager-service`, `overlay-window-service`, `overlay-visibility-service`, `overlay-bridge-service`, `overlay-runtime-init-service`
- **Shortcuts** — `shortcut-service`, `overlay-shortcut-service`, `overlay-shortcut-handler`, `shortcut-fallback-service`, `numeric-shortcut-service`
- **MPV** — `mpv-service`, `mpv-control-service`, `mpv-render-metrics-service`
- **Startup** — `startup-service`, `app-lifecycle-service`, `app-ready-service`
- **Overlay** — `overlay-manager-service`, `overlay-window-service`, `overlay-visibility-service`, `overlay-bridge-service`, `overlay-runtime-init-service`, `overlay-content-measurement-service`
- **Shortcuts** — `shortcut-service`, `overlay-shortcut-service`, `overlay-shortcut-handler`, `shortcut-fallback-service`, `numeric-shortcut-service`, `numeric-shortcut-session-service`
- **MPV** — `mpv-service`, `mpv-control-service`, `mpv-render-metrics-service`, `mpv-transport`, `mpv-protocol`, `mpv-state`, `mpv-properties`
- **IPC** — `ipc-service`, `ipc-command-service`, `runtime-options-ipc-service`
- **Mining** — `mining-service`, `field-grouping-service`, `field-grouping-overlay-service`, `anki-jimaku-service`, `anki-jimaku-ipc-service`
- **Subtitles** — `subtitle-ws-service`, `subtitle-position-service`, `secondary-subtitle-service`, `tokenizer-service`

View File

@@ -0,0 +1,154 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { AnkiIntegration } from "./anki-integration";
interface IntegrationTestContext {
integration: AnkiIntegration;
calls: {
findNotes: number;
notesInfo: number;
};
stateDir: string;
}
function createIntegrationTestContext(
options: {
highlightEnabled?: boolean;
onFindNotes?: () => Promise<number[]>;
onNotesInfo?: () => Promise<unknown[]>;
stateDirPrefix?: string;
} = {},
): IntegrationTestContext {
const calls = {
findNotes: 0,
notesInfo: 0,
};
const stateDir = fs.mkdtempSync(
path.join(os.tmpdir(), options.stateDirPrefix ?? "subminer-anki-integration-"),
);
const knownWordCacheStatePath = path.join(stateDir, "known-words-cache.json");
const client = {
findNotes: async () => {
calls.findNotes += 1;
if (options.onFindNotes) {
return options.onFindNotes();
}
return [] as number[];
},
notesInfo: async () => {
calls.notesInfo += 1;
if (options.onNotesInfo) {
return options.onNotesInfo();
}
return [] as unknown[];
},
} as {
findNotes: () => Promise<number[]>;
notesInfo: () => Promise<unknown[]>;
};
const integration = new AnkiIntegration(
{
nPlusOne: {
highlightEnabled: options.highlightEnabled ?? true,
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
knownWordCacheStatePath,
);
const integrationWithClient = integration as unknown as {
client: {
findNotes: () => Promise<number[]>;
notesInfo: () => Promise<unknown[]>;
};
};
integrationWithClient.client = client;
const privateState = integration as unknown as {
knownWordsScope: string;
knownWordsLastRefreshedAtMs: number;
};
privateState.knownWordsScope = "is:note";
privateState.knownWordsLastRefreshedAtMs = Date.now();
return {
integration,
calls,
stateDir,
};
}
function cleanupIntegrationTestContext(ctx: IntegrationTestContext): void {
fs.rmSync(ctx.stateDir, { recursive: true, force: true });
}
test("AnkiIntegration.refreshKnownWordCache bypasses stale checks", async () => {
const ctx = createIntegrationTestContext();
try {
await ctx.integration.refreshKnownWordCache();
assert.equal(ctx.calls.findNotes, 1);
assert.equal(ctx.calls.notesInfo, 0);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test("AnkiIntegration.refreshKnownWordCache skips work when highlight mode is disabled", async () => {
const ctx = createIntegrationTestContext({
highlightEnabled: false,
stateDirPrefix: "subminer-anki-integration-disabled-",
});
try {
await ctx.integration.refreshKnownWordCache();
assert.equal(ctx.calls.findNotes, 0);
assert.equal(ctx.calls.notesInfo, 0);
} finally {
cleanupIntegrationTestContext(ctx);
}
});
test("AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes", async () => {
let releaseFindNotes: (() => void) | undefined;
const findNotesPromise = new Promise<void>((resolve) => {
releaseFindNotes = resolve;
});
const ctx = createIntegrationTestContext({
onFindNotes: async () => {
await findNotesPromise;
return [] as number[];
},
stateDirPrefix: "subminer-anki-integration-concurrent-",
});
const first = ctx.integration.refreshKnownWordCache();
await Promise.resolve();
const second = ctx.integration.refreshKnownWordCache();
if (releaseFindNotes !== undefined) {
releaseFindNotes();
}
await Promise.all([first, second]);
try {
assert.equal(ctx.calls.findNotes, 1);
assert.equal(ctx.calls.notesInfo, 0);
} finally {
cleanupIntegrationTestContext(ctx);
}
});

View File

@@ -223,7 +223,11 @@ export class AnkiIntegration {
}
}
private async refreshKnownWords(): Promise<void> {
async refreshKnownWordCache(): Promise<void> {
return this.refreshKnownWords(true);
}
private async refreshKnownWords(force = false): Promise<void> {
if (!this.isKnownWordCacheEnabled()) {
log.debug("Known-word cache refresh skipped; feature disabled");
return;
@@ -232,7 +236,7 @@ export class AnkiIntegration {
log.debug("Known-word cache refresh skipped; already refreshing");
return;
}
if (!this.isKnownWordCacheStale()) {
if (!force && !this.isKnownWordCacheStale()) {
log.debug("Known-word cache refresh skipped; cache is fresh");
return;
}

View File

@@ -41,4 +41,9 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
const noCommand = parseArgs(["--verbose"]);
assert.equal(hasExplicitCommand(noCommand), false);
assert.equal(shouldStartApp(noCommand), false);
const refreshKnownWords = parseArgs(["--refresh-known-words"]);
assert.equal(refreshKnownWords.help, false);
assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false);
});

View File

@@ -16,6 +16,7 @@ export interface CliArgs {
mineSentence: boolean;
mineSentenceMultiple: boolean;
updateLastCardFromClipboard: boolean;
refreshKnownWords: boolean;
toggleSecondarySub: boolean;
triggerFieldGrouping: boolean;
triggerSubsync: boolean;
@@ -55,6 +56,7 @@ export function parseArgs(argv: string[]): CliArgs {
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownWords: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
@@ -100,6 +102,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === "--mine-sentence-multiple") args.mineSentenceMultiple = true;
else if (arg === "--update-last-card-from-clipboard")
args.updateLastCardFromClipboard = true;
else if (arg === "--refresh-known-words") args.refreshKnownWords = true;
else if (arg === "--toggle-secondary-sub") args.toggleSecondarySub = true;
else if (arg === "--trigger-field-grouping")
args.triggerFieldGrouping = true;
@@ -181,6 +184,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||

View File

@@ -17,4 +17,5 @@ test("printHelp includes configured texthooker port", () => {
assert.match(output, /--help\s+Show this help/);
assert.match(output, /default: 7777/);
assert.match(output, /--refresh-known-words/);
});

View File

@@ -19,6 +19,7 @@ SubMiner CLI commands:
--mine-sentence Mine sentence card from current subtitle
--mine-sentence-multiple Start multi-mine sentence mode
--update-last-card-from-clipboard Update last card from clipboard
--refresh-known-words Refresh known words cache now
--toggle-secondary-sub Cycle secondary subtitle mode
--trigger-field-grouping Trigger Kiku field grouping
--trigger-subsync Run subtitle sync

View File

@@ -445,9 +445,14 @@ export class ConfigService {
: isObject(ac.openRouter)
? ac.openRouter
: {};
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
ac as Record<string, unknown>;
resolved.ankiConnect = {
...resolved.ankiConnect,
...(isObject(ac) ? (ac as Partial<ResolvedConfig["ankiConnect"]>) : {}),
...(isObject(ankiConnectWithoutNPlusOne)
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig["ankiConnect"]>)
: {}),
fields: {
...resolved.ankiConnect.fields,
...(isObject(ac.fields)
@@ -606,6 +611,15 @@ export class ConfigService {
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
"Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled",
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
warn(
"ankiConnect.nPlusOne.highlightEnabled",
nPlusOneConfig.highlightEnabled,
resolved.ankiConnect.nPlusOne.highlightEnabled,
"Expected boolean.",
);
resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
} else {
resolved.ankiConnect.nPlusOne.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
@@ -734,6 +748,7 @@ export class ConfigService {
resolved.ankiConnect.nPlusOne.decks,
"Expected an array of strings.",
);
resolved.ankiConnect.nPlusOne.decks = [];
}
if (

View File

@@ -19,6 +19,7 @@ import {
export interface AnkiJimakuIpcDeps {
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiHistory: () => void;
refreshKnownWords: () => Promise<void> | void;
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
buildKikuMergePreview: (
request: KikuMergePreviewRequest,
@@ -55,6 +56,10 @@ export function registerAnkiJimakuIpcHandlers(
deps.clearAnkiHistory();
});
ipcMain.on("anki:refresh-known-words", async () => {
await deps.refreshKnownWords();
});
ipcMain.on(
"kiku:field-grouping-respond",
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {

View File

@@ -107,6 +107,7 @@ test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () =>
const expected = [
"setAnkiConnectEnabled",
"clearAnkiHistory",
"refreshKnownWords",
"respondFieldGrouping",
"buildKikuMergePreview",
"getJimakuMediaInfo",
@@ -124,6 +125,31 @@ test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () =>
}
});
test("refreshKnownWords throws when integration is unavailable", async () => {
const { registered } = createHarness();
await assert.rejects(
async () => {
await registered.refreshKnownWords();
},
{ message: "AnkiConnect integration not enabled" },
);
});
test("refreshKnownWords delegates to integration", async () => {
const { registered, state } = createHarness();
let refreshed = 0;
state.ankiIntegration = {
refreshKnownWordCache: async () => {
refreshed += 1;
},
};
await registered.refreshKnownWords();
assert.equal(refreshed, 1);
});
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
const { registered, state } = createHarness();
let destroyed = 0;

View File

@@ -10,7 +10,11 @@ import {
KikuFieldGroupingRequestData,
} from "../../types";
import { sortJimakuFiles } from "../../jimaku/utils";
import { registerAnkiJimakuIpcHandlers } from "./anki-jimaku-ipc-service";
import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc-service";
export type RegisterAnkiJimakuIpcRuntimeHandler = (
deps: AnkiJimakuIpcDeps,
) => void;
interface MpvClientLike {
connected: boolean;
@@ -60,7 +64,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
export function registerAnkiJimakuIpcRuntimeService(
options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
): void {
registerHandlers({
setAnkiConnectEnabled: (enabled) => {
@@ -108,6 +112,13 @@ export function registerAnkiJimakuIpcRuntimeService(
console.log("AnkiConnect subtitle timing history cleared");
}
},
refreshKnownWords: async () => {
const integration = options.getAnkiIntegration();
if (!integration) {
throw new Error("AnkiConnect integration not enabled");
}
await integration.refreshKnownWordCache();
},
respondFieldGrouping: (choice) => {
const resolver = options.getFieldGroupingResolver();
if (resolver) {

View File

@@ -26,6 +26,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
refreshKnownWords: false,
openRuntimeOptions: false,
texthooker: false,
help: false,
@@ -106,6 +107,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
updateLastCardFromClipboard: async () => {
calls.push("updateLastCardFromClipboard");
},
refreshKnownWords: async () => {
calls.push("refreshKnownWords");
},
cycleSecondarySubMode: () => {
calls.push("cycleSecondarySubMode");
},
@@ -288,3 +292,27 @@ test("handleCliCommandService handles visibility and utility command dispatches"
);
}
});
test("handleCliCommandService runs refresh-known-words command", () => {
const { deps, calls } = createDeps();
handleCliCommandService(makeArgs({ refreshKnownWords: true }), "initial", deps);
assert.ok(calls.includes("refreshKnownWords"));
});
test("handleCliCommandService reports async refresh-known-words errors to OSD", async () => {
const { deps, calls, osd } = createDeps({
refreshKnownWords: async () => {
throw new Error("refresh boom");
},
});
handleCliCommandService(makeArgs({ refreshKnownWords: true }), "initial", deps);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(
calls.some((value) => value.startsWith("error:refreshKnownWords failed:")),
);
assert.ok(osd.some((value) => value.includes("Refresh known words failed: refresh boom")));
});

View File

@@ -29,6 +29,7 @@ export interface CliCommandServiceDeps {
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWords: () => Promise<void>;
cycleSecondarySubMode: () => void;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
@@ -83,6 +84,7 @@ interface MiningCliRuntime {
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWords: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
@@ -159,6 +161,7 @@ export function createCliCommandDepsRuntimeService(
startPendingMineSentenceMultiple:
options.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard,
refreshKnownWords: options.mining.refreshKnownWords,
cycleSecondarySubMode: options.ui.cycleSecondarySubMode,
triggerFieldGrouping: options.mining.triggerFieldGrouping,
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
@@ -208,6 +211,7 @@ export function handleCliCommandService(
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
@@ -295,6 +299,13 @@ export function handleCliCommandService(
"updateLastCardFromClipboard",
"Update failed",
);
} else if (args.refreshKnownWords) {
runAsyncWithOsd(
() => deps.refreshKnownWords(),
deps,
"refreshKnownWords",
"Refresh known words failed",
);
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {

View File

@@ -704,6 +704,7 @@ function handleCliCommand(
startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
refreshKnownWordCache: () => refreshKnownWordCache(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
@@ -1110,6 +1111,14 @@ async function updateLastCardFromClipboard(): Promise<void> {
);
}
async function refreshKnownWordCache(): Promise<void> {
if (!appState.ankiIntegration) {
throw new Error("AnkiConnect integration not enabled");
}
await appState.ankiIntegration.refreshKnownWordCache();
}
async function triggerFieldGrouping(): Promise<void> {
await triggerFieldGroupingService(
{

View File

@@ -22,6 +22,7 @@ export interface CliCommandRuntimeServiceContext {
mineSentenceCard: () => Promise<void>;
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
updateLastCardFromClipboard: () => Promise<void>;
refreshKnownWordCache: () => Promise<void>;
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
@@ -73,6 +74,7 @@ function createCliCommandDepsFromContext(
mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
refreshKnownWords: context.refreshKnownWordCache,
triggerFieldGrouping: context.triggerFieldGrouping,
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
markLastCardAsAudioCard: context.markLastCardAsAudioCard,

View File

@@ -145,6 +145,7 @@ export interface CliCommandRuntimeServiceDepsParams {
CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
updateLastCardFromClipboard:
CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
refreshKnownWords: CliCommandDepsRuntimeOptions["mining"]["refreshKnownWords"];
triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"];
triggerSubsyncFromConfig:
CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
@@ -273,6 +274,7 @@ export function createCliCommandRuntimeServiceDeps(
mineSentenceCard: params.mining.mineSentenceCard,
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
refreshKnownWords: params.mining.refreshKnownWords,
triggerFieldGrouping: params.mining.triggerFieldGrouping,
triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard,

View File

@@ -3,6 +3,7 @@ import {
registerAnkiJimakuIpcRuntimeService,
registerIpcHandlersService,
} from "../core/services";
import { registerAnkiJimakuIpcHandlers } from "../core/services/anki-jimaku-ipc-service";
import {
createAnkiJimakuIpcRuntimeServiceDeps,
AnkiJimakuIpcRuntimeServiceDepsParams,
@@ -34,6 +35,7 @@ export function registerAnkiJimakuIpcRuntimeServices(
): void {
registerAnkiJimakuIpcRuntimeService(
createAnkiJimakuIpcRuntimeServiceDeps(params),
registerAnkiJimakuIpcHandlers,
);
}