mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
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:
@@ -16,11 +16,23 @@ SubMiner uses a service-oriented Electron architecture with a composition-orient
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
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
|
preload.ts # Electron preload bridge
|
||||||
types.ts # Shared type definitions
|
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/
|
core/
|
||||||
services/ # ~55 focused service modules (see below)
|
services/ # ~60 focused service modules (see below)
|
||||||
utils/ # Pure helpers and coercion/config utilities
|
utils/ # Pure helpers and coercion/config utilities
|
||||||
cli/ # CLI parsing and help output
|
cli/ # CLI parsing and help output
|
||||||
config/ # Config schema, defaults, validation, template generation
|
config/ # Config schema, defaults, validation, template generation
|
||||||
@@ -36,10 +48,10 @@ src/
|
|||||||
|
|
||||||
### Service Layer (`src/core/services/`)
|
### Service Layer (`src/core/services/`)
|
||||||
|
|
||||||
- **Startup** — `startup-service`, `app-lifecycle-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** — `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`
|
- **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** — `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`
|
- **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`
|
- **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`
|
- **Subtitles** — `subtitle-ws-service`, `subtitle-position-service`, `secondary-subtitle-service`, `tokenizer-service`
|
||||||
|
|||||||
154
src/anki-integration.test.ts
Normal file
154
src/anki-integration.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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()) {
|
if (!this.isKnownWordCacheEnabled()) {
|
||||||
log.debug("Known-word cache refresh skipped; feature disabled");
|
log.debug("Known-word cache refresh skipped; feature disabled");
|
||||||
return;
|
return;
|
||||||
@@ -232,7 +236,7 @@ export class AnkiIntegration {
|
|||||||
log.debug("Known-word cache refresh skipped; already refreshing");
|
log.debug("Known-word cache refresh skipped; already refreshing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.isKnownWordCacheStale()) {
|
if (!force && !this.isKnownWordCacheStale()) {
|
||||||
log.debug("Known-word cache refresh skipped; cache is fresh");
|
log.debug("Known-word cache refresh skipped; cache is fresh");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,9 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
|
|||||||
const noCommand = parseArgs(["--verbose"]);
|
const noCommand = parseArgs(["--verbose"]);
|
||||||
assert.equal(hasExplicitCommand(noCommand), false);
|
assert.equal(hasExplicitCommand(noCommand), false);
|
||||||
assert.equal(shouldStartApp(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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface CliArgs {
|
|||||||
mineSentence: boolean;
|
mineSentence: boolean;
|
||||||
mineSentenceMultiple: boolean;
|
mineSentenceMultiple: boolean;
|
||||||
updateLastCardFromClipboard: boolean;
|
updateLastCardFromClipboard: boolean;
|
||||||
|
refreshKnownWords: boolean;
|
||||||
toggleSecondarySub: boolean;
|
toggleSecondarySub: boolean;
|
||||||
triggerFieldGrouping: boolean;
|
triggerFieldGrouping: boolean;
|
||||||
triggerSubsync: boolean;
|
triggerSubsync: boolean;
|
||||||
@@ -55,6 +56,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
mineSentence: false,
|
mineSentence: false,
|
||||||
mineSentenceMultiple: false,
|
mineSentenceMultiple: false,
|
||||||
updateLastCardFromClipboard: false,
|
updateLastCardFromClipboard: false,
|
||||||
|
refreshKnownWords: false,
|
||||||
toggleSecondarySub: false,
|
toggleSecondarySub: false,
|
||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: 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 === "--mine-sentence-multiple") args.mineSentenceMultiple = true;
|
||||||
else if (arg === "--update-last-card-from-clipboard")
|
else if (arg === "--update-last-card-from-clipboard")
|
||||||
args.updateLastCardFromClipboard = true;
|
args.updateLastCardFromClipboard = true;
|
||||||
|
else if (arg === "--refresh-known-words") args.refreshKnownWords = true;
|
||||||
else if (arg === "--toggle-secondary-sub") args.toggleSecondarySub = true;
|
else if (arg === "--toggle-secondary-sub") args.toggleSecondarySub = true;
|
||||||
else if (arg === "--trigger-field-grouping")
|
else if (arg === "--trigger-field-grouping")
|
||||||
args.triggerFieldGrouping = true;
|
args.triggerFieldGrouping = true;
|
||||||
@@ -181,6 +184,7 @@ export function hasExplicitCommand(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 ||
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ test("printHelp includes configured texthooker port", () => {
|
|||||||
|
|
||||||
assert.match(output, /--help\s+Show this help/);
|
assert.match(output, /--help\s+Show this help/);
|
||||||
assert.match(output, /default: 7777/);
|
assert.match(output, /default: 7777/);
|
||||||
|
assert.match(output, /--refresh-known-words/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ SubMiner CLI commands:
|
|||||||
--copy-subtitle-multiple Start multi-copy mode
|
--copy-subtitle-multiple Start multi-copy mode
|
||||||
--mine-sentence Mine sentence card from current subtitle
|
--mine-sentence Mine sentence card from current subtitle
|
||||||
--mine-sentence-multiple Start multi-mine sentence mode
|
--mine-sentence-multiple Start multi-mine sentence mode
|
||||||
--update-last-card-from-clipboard Update last card from clipboard
|
--update-last-card-from-clipboard Update last card from clipboard
|
||||||
--toggle-secondary-sub Cycle secondary subtitle mode
|
--refresh-known-words Refresh known words cache now
|
||||||
|
--toggle-secondary-sub Cycle secondary subtitle mode
|
||||||
--trigger-field-grouping Trigger Kiku field grouping
|
--trigger-field-grouping Trigger Kiku field grouping
|
||||||
--trigger-subsync Run subtitle sync
|
--trigger-subsync Run subtitle sync
|
||||||
--mark-audio-card Mark last card as audio card
|
--mark-audio-card Mark last card as audio card
|
||||||
|
|||||||
@@ -445,9 +445,14 @@ export class ConfigService {
|
|||||||
: isObject(ac.openRouter)
|
: isObject(ac.openRouter)
|
||||||
? ac.openRouter
|
? ac.openRouter
|
||||||
: {};
|
: {};
|
||||||
|
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
|
||||||
|
ac as Record<string, unknown>;
|
||||||
|
|
||||||
resolved.ankiConnect = {
|
resolved.ankiConnect = {
|
||||||
...resolved.ankiConnect,
|
...resolved.ankiConnect,
|
||||||
...(isObject(ac) ? (ac as Partial<ResolvedConfig["ankiConnect"]>) : {}),
|
...(isObject(ankiConnectWithoutNPlusOne)
|
||||||
|
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig["ankiConnect"]>)
|
||||||
|
: {}),
|
||||||
fields: {
|
fields: {
|
||||||
...resolved.ankiConnect.fields,
|
...resolved.ankiConnect.fields,
|
||||||
...(isObject(ac.fields)
|
...(isObject(ac.fields)
|
||||||
@@ -598,17 +603,26 @@ export class ConfigService {
|
|||||||
behavior.nPlusOneHighlightEnabled,
|
behavior.nPlusOneHighlightEnabled,
|
||||||
);
|
);
|
||||||
if (legacyNPlusOneHighlightEnabled !== undefined) {
|
if (legacyNPlusOneHighlightEnabled !== undefined) {
|
||||||
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
||||||
legacyNPlusOneHighlightEnabled;
|
legacyNPlusOneHighlightEnabled;
|
||||||
warn(
|
warn(
|
||||||
"ankiConnect.behavior.nPlusOneHighlightEnabled",
|
"ankiConnect.behavior.nPlusOneHighlightEnabled",
|
||||||
behavior.nPlusOneHighlightEnabled,
|
behavior.nPlusOneHighlightEnabled,
|
||||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
|
||||||
"Legacy key is deprecated; use 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 {
|
} else {
|
||||||
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
resolved.ankiConnect.nPlusOne.highlightEnabled =
|
||||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
|
DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +748,7 @@ export class ConfigService {
|
|||||||
resolved.ankiConnect.nPlusOne.decks,
|
resolved.ankiConnect.nPlusOne.decks,
|
||||||
"Expected an array of strings.",
|
"Expected an array of strings.",
|
||||||
);
|
);
|
||||||
|
resolved.ankiConnect.nPlusOne.decks = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
export interface AnkiJimakuIpcDeps {
|
export interface AnkiJimakuIpcDeps {
|
||||||
setAnkiConnectEnabled: (enabled: boolean) => void;
|
setAnkiConnectEnabled: (enabled: boolean) => void;
|
||||||
clearAnkiHistory: () => void;
|
clearAnkiHistory: () => void;
|
||||||
|
refreshKnownWords: () => Promise<void> | void;
|
||||||
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
|
respondFieldGrouping: (choice: KikuFieldGroupingChoice) => void;
|
||||||
buildKikuMergePreview: (
|
buildKikuMergePreview: (
|
||||||
request: KikuMergePreviewRequest,
|
request: KikuMergePreviewRequest,
|
||||||
@@ -55,6 +56,10 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
deps.clearAnkiHistory();
|
deps.clearAnkiHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on("anki:refresh-known-words", async () => {
|
||||||
|
await deps.refreshKnownWords();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on(
|
ipcMain.on(
|
||||||
"kiku:field-grouping-respond",
|
"kiku:field-grouping-respond",
|
||||||
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {
|
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () =>
|
|||||||
const expected = [
|
const expected = [
|
||||||
"setAnkiConnectEnabled",
|
"setAnkiConnectEnabled",
|
||||||
"clearAnkiHistory",
|
"clearAnkiHistory",
|
||||||
|
"refreshKnownWords",
|
||||||
"respondFieldGrouping",
|
"respondFieldGrouping",
|
||||||
"buildKikuMergePreview",
|
"buildKikuMergePreview",
|
||||||
"getJimakuMediaInfo",
|
"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", () => {
|
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
|
||||||
const { registered, state } = createHarness();
|
const { registered, state } = createHarness();
|
||||||
let destroyed = 0;
|
let destroyed = 0;
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
KikuFieldGroupingRequestData,
|
KikuFieldGroupingRequestData,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { sortJimakuFiles } from "../../jimaku/utils";
|
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 {
|
interface MpvClientLike {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -60,7 +64,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
|||||||
|
|
||||||
export function registerAnkiJimakuIpcRuntimeService(
|
export function registerAnkiJimakuIpcRuntimeService(
|
||||||
options: AnkiJimakuIpcRuntimeOptions,
|
options: AnkiJimakuIpcRuntimeOptions,
|
||||||
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
|
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
|
||||||
): void {
|
): void {
|
||||||
registerHandlers({
|
registerHandlers({
|
||||||
setAnkiConnectEnabled: (enabled) => {
|
setAnkiConnectEnabled: (enabled) => {
|
||||||
@@ -108,6 +112,13 @@ export function registerAnkiJimakuIpcRuntimeService(
|
|||||||
console.log("AnkiConnect subtitle timing history cleared");
|
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) => {
|
respondFieldGrouping: (choice) => {
|
||||||
const resolver = options.getFieldGroupingResolver();
|
const resolver = options.getFieldGroupingResolver();
|
||||||
if (resolver) {
|
if (resolver) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
triggerFieldGrouping: false,
|
triggerFieldGrouping: false,
|
||||||
triggerSubsync: false,
|
triggerSubsync: false,
|
||||||
markAudioCard: false,
|
markAudioCard: false,
|
||||||
|
refreshKnownWords: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
texthooker: false,
|
texthooker: false,
|
||||||
help: false,
|
help: false,
|
||||||
@@ -106,6 +107,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
updateLastCardFromClipboard: async () => {
|
updateLastCardFromClipboard: async () => {
|
||||||
calls.push("updateLastCardFromClipboard");
|
calls.push("updateLastCardFromClipboard");
|
||||||
},
|
},
|
||||||
|
refreshKnownWords: async () => {
|
||||||
|
calls.push("refreshKnownWords");
|
||||||
|
},
|
||||||
cycleSecondarySubMode: () => {
|
cycleSecondarySubMode: () => {
|
||||||
calls.push("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")));
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface CliCommandServiceDeps {
|
|||||||
mineSentenceCard: () => Promise<void>;
|
mineSentenceCard: () => Promise<void>;
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
updateLastCardFromClipboard: () => Promise<void>;
|
||||||
|
refreshKnownWords: () => Promise<void>;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
@@ -83,6 +84,7 @@ interface MiningCliRuntime {
|
|||||||
mineSentenceCard: () => Promise<void>;
|
mineSentenceCard: () => Promise<void>;
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
updateLastCardFromClipboard: () => Promise<void>;
|
||||||
|
refreshKnownWords: () => Promise<void>;
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
@@ -159,6 +161,7 @@ export function createCliCommandDepsRuntimeService(
|
|||||||
startPendingMineSentenceMultiple:
|
startPendingMineSentenceMultiple:
|
||||||
options.mining.startPendingMineSentenceMultiple,
|
options.mining.startPendingMineSentenceMultiple,
|
||||||
updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard,
|
updateLastCardFromClipboard: options.mining.updateLastCardFromClipboard,
|
||||||
|
refreshKnownWords: options.mining.refreshKnownWords,
|
||||||
cycleSecondarySubMode: options.ui.cycleSecondarySubMode,
|
cycleSecondarySubMode: options.ui.cycleSecondarySubMode,
|
||||||
triggerFieldGrouping: options.mining.triggerFieldGrouping,
|
triggerFieldGrouping: options.mining.triggerFieldGrouping,
|
||||||
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
|
||||||
@@ -208,6 +211,7 @@ export function handleCliCommandService(
|
|||||||
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 ||
|
||||||
@@ -295,6 +299,13 @@ export function handleCliCommandService(
|
|||||||
"updateLastCardFromClipboard",
|
"updateLastCardFromClipboard",
|
||||||
"Update failed",
|
"Update failed",
|
||||||
);
|
);
|
||||||
|
} else if (args.refreshKnownWords) {
|
||||||
|
runAsyncWithOsd(
|
||||||
|
() => deps.refreshKnownWords(),
|
||||||
|
deps,
|
||||||
|
"refreshKnownWords",
|
||||||
|
"Refresh known words failed",
|
||||||
|
);
|
||||||
} else if (args.toggleSecondarySub) {
|
} else if (args.toggleSecondarySub) {
|
||||||
deps.cycleSecondarySubMode();
|
deps.cycleSecondarySubMode();
|
||||||
} else if (args.triggerFieldGrouping) {
|
} else if (args.triggerFieldGrouping) {
|
||||||
|
|||||||
@@ -704,6 +704,7 @@ function handleCliCommand(
|
|||||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||||
startPendingMineSentenceMultiple(timeoutMs),
|
startPendingMineSentenceMultiple(timeoutMs),
|
||||||
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
|
||||||
|
refreshKnownWordCache: () => refreshKnownWordCache(),
|
||||||
triggerFieldGrouping: () => triggerFieldGrouping(),
|
triggerFieldGrouping: () => triggerFieldGrouping(),
|
||||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
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> {
|
async function triggerFieldGrouping(): Promise<void> {
|
||||||
await triggerFieldGroupingService(
|
await triggerFieldGroupingService(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
mineSentenceCard: () => Promise<void>;
|
mineSentenceCard: () => Promise<void>;
|
||||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||||
updateLastCardFromClipboard: () => Promise<void>;
|
updateLastCardFromClipboard: () => Promise<void>;
|
||||||
|
refreshKnownWordCache: () => Promise<void>;
|
||||||
triggerFieldGrouping: () => Promise<void>;
|
triggerFieldGrouping: () => Promise<void>;
|
||||||
triggerSubsyncFromConfig: () => Promise<void>;
|
triggerSubsyncFromConfig: () => Promise<void>;
|
||||||
markLastCardAsAudioCard: () => Promise<void>;
|
markLastCardAsAudioCard: () => Promise<void>;
|
||||||
@@ -70,12 +71,13 @@ function createCliCommandDepsFromContext(
|
|||||||
mining: {
|
mining: {
|
||||||
copyCurrentSubtitle: context.copyCurrentSubtitle,
|
copyCurrentSubtitle: context.copyCurrentSubtitle,
|
||||||
startPendingMultiCopy: context.startPendingMultiCopy,
|
startPendingMultiCopy: context.startPendingMultiCopy,
|
||||||
mineSentenceCard: context.mineSentenceCard,
|
mineSentenceCard: context.mineSentenceCard,
|
||||||
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
|
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
|
||||||
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
|
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
|
||||||
triggerFieldGrouping: context.triggerFieldGrouping,
|
refreshKnownWords: context.refreshKnownWordCache,
|
||||||
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
|
triggerFieldGrouping: context.triggerFieldGrouping,
|
||||||
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
|
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
|
||||||
|
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
|
||||||
},
|
},
|
||||||
ui: {
|
ui: {
|
||||||
openYomitanSettings: context.openYomitanSettings,
|
openYomitanSettings: context.openYomitanSettings,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
|
CliCommandDepsRuntimeOptions["mining"]["startPendingMineSentenceMultiple"];
|
||||||
updateLastCardFromClipboard:
|
updateLastCardFromClipboard:
|
||||||
CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
|
CliCommandDepsRuntimeOptions["mining"]["updateLastCardFromClipboard"];
|
||||||
|
refreshKnownWords: CliCommandDepsRuntimeOptions["mining"]["refreshKnownWords"];
|
||||||
triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"];
|
triggerFieldGrouping: CliCommandDepsRuntimeOptions["mining"]["triggerFieldGrouping"];
|
||||||
triggerSubsyncFromConfig:
|
triggerSubsyncFromConfig:
|
||||||
CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
|
CliCommandDepsRuntimeOptions["mining"]["triggerSubsyncFromConfig"];
|
||||||
@@ -273,6 +274,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
mineSentenceCard: params.mining.mineSentenceCard,
|
mineSentenceCard: params.mining.mineSentenceCard,
|
||||||
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
|
startPendingMineSentenceMultiple: params.mining.startPendingMineSentenceMultiple,
|
||||||
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
|
updateLastCardFromClipboard: params.mining.updateLastCardFromClipboard,
|
||||||
|
refreshKnownWords: params.mining.refreshKnownWords,
|
||||||
triggerFieldGrouping: params.mining.triggerFieldGrouping,
|
triggerFieldGrouping: params.mining.triggerFieldGrouping,
|
||||||
triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig,
|
||||||
markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard,
|
markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
registerAnkiJimakuIpcRuntimeService,
|
registerAnkiJimakuIpcRuntimeService,
|
||||||
registerIpcHandlersService,
|
registerIpcHandlersService,
|
||||||
} from "../core/services";
|
} from "../core/services";
|
||||||
|
import { registerAnkiJimakuIpcHandlers } from "../core/services/anki-jimaku-ipc-service";
|
||||||
import {
|
import {
|
||||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||||
@@ -34,6 +35,7 @@ export function registerAnkiJimakuIpcRuntimeServices(
|
|||||||
): void {
|
): void {
|
||||||
registerAnkiJimakuIpcRuntimeService(
|
registerAnkiJimakuIpcRuntimeService(
|
||||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||||
|
registerAnkiJimakuIpcHandlers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user