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

@@ -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) {