Complete runtime service follow-ups and invisible subtitle edit mode

This commit is contained in:
2026-02-10 19:48:23 -08:00
parent b6f3d0aad3
commit cfdc6668df
35 changed files with 1293 additions and 461 deletions

View File

@@ -0,0 +1,228 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
AnkiJimakuIpcRuntimeOptions,
registerAnkiJimakuIpcRuntimeService,
} from "./anki-jimaku-service";
interface RuntimeHarness {
options: AnkiJimakuIpcRuntimeOptions;
registered: Record<string, (...args: unknown[]) => unknown>;
state: {
ankiIntegration: unknown;
fieldGroupingResolver: ((choice: unknown) => void) | null;
patches: boolean[];
broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: string[] }>;
};
}
function createHarness(): RuntimeHarness {
const state = {
ankiIntegration: null as unknown,
fieldGroupingResolver: null as ((choice: unknown) => void) | null,
patches: [] as boolean[],
broadcasts: 0,
fetchCalls: [] as Array<{ endpoint: string; query?: Record<string, unknown> }>,
sentCommands: [] as Array<{ command: string[] }>,
};
const options: AnkiJimakuIpcRuntimeOptions = {
patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled);
},
getResolvedConfig: () => ({}),
getRuntimeOptionsManager: () => null,
getSubtitleTimingTracker: () => null,
getMpvClient: () => ({
connected: true,
send: (payload) => {
state.sentCommands.push(payload);
},
}),
getAnkiIntegration: () => state.ankiIntegration as never,
setAnkiIntegration: (integration) => {
state.ankiIntegration = integration;
},
showDesktopNotification: () => {},
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: false,
}),
broadcastRuntimeOptionsChanged: () => {
state.broadcasts += 1;
},
getFieldGroupingResolver: () => state.fieldGroupingResolver as never,
setFieldGroupingResolver: (resolver) => {
state.fieldGroupingResolver = resolver as never;
},
parseMediaInfo: () => ({
title: "video",
confidence: "high",
rawTitle: "video",
filename: "video.mkv",
season: null,
episode: null,
}),
getCurrentMediaPath: () => "/tmp/video.mkv",
jimakuFetchJson: async (endpoint, query) => {
state.fetchCalls.push({ endpoint, query: query as Record<string, unknown> });
return {
ok: true,
data: [
{ id: 1, name: "a" },
{ id: 2, name: "b" },
{ id: 3, name: "c" },
] as never,
};
},
getJimakuMaxEntryResults: () => 2,
getJimakuLanguagePreference: () => "ja",
resolveJimakuApiKey: async () => "token",
isRemoteMediaPath: () => false,
downloadToFile: async (url, destPath) => ({
ok: true,
path: `${destPath}:${url}`,
}),
};
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntimeService(
options,
(deps) => {
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
},
);
return { options, registered, state };
}
test("registerAnkiJimakuIpcRuntimeService provides full handler surface", () => {
const { registered } = createHarness();
const expected = [
"setAnkiConnectEnabled",
"clearAnkiHistory",
"respondFieldGrouping",
"buildKikuMergePreview",
"getJimakuMediaInfo",
"searchJimakuEntries",
"listJimakuFiles",
"resolveJimakuApiKey",
"getCurrentMediaPath",
"isRemoteMediaPath",
"downloadToFile",
"onDownloadedSubtitle",
];
for (const key of expected) {
assert.equal(typeof registered[key], "function", `missing handler: ${key}`);
}
});
test("setAnkiConnectEnabled disables active integration and broadcasts changes", () => {
const { registered, state } = createHarness();
let destroyed = 0;
state.ankiIntegration = {
destroy: () => {
destroyed += 1;
},
};
registered.setAnkiConnectEnabled(false);
assert.deepEqual(state.patches, [false]);
assert.equal(destroyed, 1);
assert.equal(state.ankiIntegration, null);
assert.equal(state.broadcasts, 1);
});
test("clearAnkiHistory and respondFieldGrouping execute runtime callbacks", () => {
const { registered, state, options } = createHarness();
let cleaned = 0;
let resolvedChoice: unknown = null;
state.fieldGroupingResolver = (choice) => {
resolvedChoice = choice;
};
const originalGetTracker = options.getSubtitleTimingTracker;
options.getSubtitleTimingTracker = () =>
({ cleanup: () => {
cleaned += 1;
} }) as never;
const choice = {
keepNoteId: 10,
deleteNoteId: 11,
deleteDuplicate: true,
cancelled: false,
};
registered.clearAnkiHistory();
registered.respondFieldGrouping(choice);
options.getSubtitleTimingTracker = originalGetTracker;
assert.equal(cleaned, 1);
assert.deepEqual(resolvedChoice, choice);
assert.equal(state.fieldGroupingResolver, null);
});
test("buildKikuMergePreview returns guard error when integration is missing", async () => {
const { registered } = createHarness();
const result = await registered.buildKikuMergePreview({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
});
assert.deepEqual(result, {
ok: false,
error: "AnkiConnect integration not enabled",
});
});
test("buildKikuMergePreview delegates to integration when available", async () => {
const { registered, state } = createHarness();
const calls: unknown[] = [];
state.ankiIntegration = {
buildFieldGroupingPreview: async (
keepNoteId: number,
deleteNoteId: number,
deleteDuplicate: boolean,
) => {
calls.push([keepNoteId, deleteNoteId, deleteDuplicate]);
return { ok: true };
},
};
const result = await registered.buildKikuMergePreview({
keepNoteId: 3,
deleteNoteId: 4,
deleteDuplicate: true,
});
assert.deepEqual(calls, [[3, 4, true]]);
assert.deepEqual(result, { ok: true });
});
test("searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to mpv", async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchJimakuEntries({ query: "test" });
assert.deepEqual(state.fetchCalls, [
{
endpoint: "/api/entries/search",
query: { anime: true, query: "test" },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
assert.equal((searchResult as { data: unknown[] }).data.length, 2);
registered.onDownloadedSubtitle("/tmp/subtitle.ass");
assert.deepEqual(state.sentCommands, [
{ command: ["sub-add", "/tmp/subtitle.ass", "select"] },
]);
});

View File

@@ -59,8 +59,9 @@ export interface AnkiJimakuIpcRuntimeOptions {
export function registerAnkiJimakuIpcRuntimeService(
options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: typeof registerAnkiJimakuIpcHandlers = registerAnkiJimakuIpcHandlers,
): void {
registerAnkiJimakuIpcHandlers({
registerHandlers({
setAnkiConnectEnabled: (enabled) => {
options.patchAnkiConnectEnabled(enabled);
const config = options.getResolvedConfig();

View File

@@ -2,7 +2,7 @@ export { TexthookerService } from "./texthooker-service";
export { hasMpvWebsocketPlugin, SubtitleWebSocketService } from "./subtitle-ws-service";
export { registerGlobalShortcutsService } from "./shortcut-service";
export { createIpcDepsRuntimeService, registerIpcHandlersService } from "./ipc-service";
export { isGlobalShortcutRegisteredSafe, shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
export { shortcutMatchesInputForLocalFallback } from "./shortcut-fallback-service";
export {
refreshOverlayShortcutsRuntimeService,
registerOverlayShortcutsService,

View File

@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
copyCurrentSubtitleService,
handleMineSentenceDigitService,
handleMultiCopyDigitService,
mineSentenceCardService,
} from "./mining-service";
test("copyCurrentSubtitleService reports tracker and subtitle guards", () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitleService({
subtitleTimingTracker: null,
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "Subtitle tracker not available");
copyCurrentSubtitleService({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "No current subtitle");
assert.deepEqual(copied, []);
});
test("copyCurrentSubtitleService copies current subtitle text", () => {
const osd: string[] = [];
const copied: string[] = [];
copyCurrentSubtitleService({
subtitleTimingTracker: {
getRecentBlocks: () => [],
getCurrentSubtitle: () => "hello world",
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ["hello world"]);
assert.equal(osd.at(-1), "Copied subtitle");
});
test("mineSentenceCardService handles missing integration and disconnected mpv", async () => {
const osd: string[] = [];
await mineSentenceCardService({
ankiIntegration: null,
mpvClient: null,
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "AnkiConnect integration not enabled");
await mineSentenceCardService({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {},
},
mpvClient: {
connected: false,
currentSubText: "line",
currentSubStart: 1,
currentSubEnd: 2,
},
showMpvOsd: (text) => osd.push(text),
});
assert.equal(osd.at(-1), "MPV not connected");
});
test("mineSentenceCardService creates sentence card from mpv subtitle state", async () => {
const created: Array<{
sentence: string;
startTime: number;
endTime: number;
secondarySub?: string;
}> = [];
await mineSentenceCardService({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
},
},
mpvClient: {
connected: true,
currentSubText: "subtitle line",
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: "secondary line",
},
showMpvOsd: () => {},
});
assert.deepEqual(created, [
{
sentence: "subtitle line",
startTime: 10,
endTime: 12,
secondarySub: "secondary line",
},
]);
});
test("handleMultiCopyDigitService copies available history and reports truncation", () => {
const osd: string[] = [];
const copied: string[] = [];
handleMultiCopyDigitService(5, {
subtitleTimingTracker: {
getRecentBlocks: (count) => ["a", "b"].slice(0, count),
getCurrentSubtitle: () => null,
findTiming: () => null,
},
writeClipboardText: (text) => copied.push(text),
showMpvOsd: (text) => osd.push(text),
});
assert.deepEqual(copied, ["a\n\nb"]);
assert.equal(osd.at(-1), "Only 2 lines available, copied 2");
});
test("handleMineSentenceDigitService reports async create failures", async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
handleMineSentenceDigitService(2, {
subtitleTimingTracker: {
getRecentBlocks: () => ["one", "two"],
getCurrentSubtitle: () => null,
findTiming: (text) =>
text === "one"
? { startTime: 1, endTime: 3 }
: { startTime: 4, endTime: 7 },
},
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async () => {
throw new Error("mine boom");
},
},
getCurrentSecondarySubText: () => "sub2",
showMpvOsd: (text) => osd.push(text),
logError: (message, err) => logs.push({ message, err }),
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(logs[0]?.message, "mineSentenceMultiple failed:");
assert.equal((logs[0]?.err as Error).message, "mine boom");
assert.ok(osd.some((entry) => entry.includes("Mine sentence failed: mine boom")));
});

View File

@@ -0,0 +1,256 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ConfiguredShortcuts } from "../utils/shortcut-config";
import {
createOverlayShortcutRuntimeHandlers,
OverlayShortcutRuntimeDeps,
runOverlayShortcutLocalFallback,
} from "./overlay-shortcut-handler";
function makeShortcuts(
overrides: Partial<ConfiguredShortcuts> = {},
): ConfiguredShortcuts {
return {
toggleVisibleOverlayGlobal: null,
toggleInvisibleOverlayGlobal: null,
copySubtitle: null,
copySubtitleMultiple: null,
updateLastCardFromClipboard: null,
triggerFieldGrouping: null,
triggerSubsync: null,
mineSentence: null,
mineSentenceMultiple: null,
multiCopyTimeoutMs: 2500,
toggleSecondarySub: null,
markAudioCard: null,
openRuntimeOptions: null,
openJimaku: null,
...overrides,
};
}
function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
const calls: string[] = [];
const osd: string[] = [];
const deps: OverlayShortcutRuntimeDeps = {
showMpvOsd: (text) => {
osd.push(text);
},
openRuntimeOptions: () => {
calls.push("openRuntimeOptions");
},
openJimaku: () => {
calls.push("openJimaku");
},
markAudioCard: async () => {
calls.push("markAudioCard");
},
copySubtitleMultiple: (timeoutMs) => {
calls.push(`copySubtitleMultiple:${timeoutMs}`);
},
copySubtitle: () => {
calls.push("copySubtitle");
},
toggleSecondarySub: () => {
calls.push("toggleSecondarySub");
},
updateLastCardFromClipboard: async () => {
calls.push("updateLastCardFromClipboard");
},
triggerFieldGrouping: async () => {
calls.push("triggerFieldGrouping");
},
triggerSubsync: async () => {
calls.push("triggerSubsync");
},
mineSentence: async () => {
calls.push("mineSentence");
},
mineSentenceMultiple: (timeoutMs) => {
calls.push(`mineSentenceMultiple:${timeoutMs}`);
},
...overrides,
};
return { deps, calls, osd };
}
test("createOverlayShortcutRuntimeHandlers dispatches sync and async handlers", async () => {
const { deps, calls } = createDeps();
const { overlayHandlers, fallbackHandlers } =
createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.copySubtitle();
overlayHandlers.copySubtitleMultiple(1111);
overlayHandlers.toggleSecondarySub();
overlayHandlers.openRuntimeOptions();
overlayHandlers.openJimaku();
overlayHandlers.mineSentenceMultiple(2222);
overlayHandlers.updateLastCardFromClipboard();
fallbackHandlers.mineSentence();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
"copySubtitle",
"copySubtitleMultiple:1111",
"toggleSecondarySub",
"openRuntimeOptions",
"openJimaku",
"mineSentenceMultiple:2222",
"updateLastCardFromClipboard",
"mineSentence",
]);
});
test("createOverlayShortcutRuntimeHandlers reports async failures via OSD", async () => {
const logs: unknown[][] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
logs.push(args);
};
try {
const { deps, osd } = createDeps({
markAudioCard: async () => {
throw new Error("audio boom");
},
});
const { overlayHandlers } = createOverlayShortcutRuntimeHandlers(deps);
overlayHandlers.markAudioCard();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(logs.length, 1);
assert.equal(logs[0]?.[0], "markLastCardAsAudioCard failed:");
assert.ok(osd.some((entry) => entry.includes("Audio card failed: audio boom")));
} finally {
console.error = originalError;
}
});
test("runOverlayShortcutLocalFallback dispatches matching actions with timeout", () => {
const handled: string[] = [];
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
copySubtitleMultiple: "Ctrl+M",
multiCopyTimeoutMs: 4321,
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+M";
},
{
openRuntimeOptions: () => handled.push("openRuntimeOptions"),
openJimaku: () => handled.push("openJimaku"),
markAudioCard: () => handled.push("markAudioCard"),
copySubtitleMultiple: (timeoutMs) =>
handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push("copySubtitle"),
toggleSecondarySub: () => handled.push("toggleSecondarySub"),
updateLastCardFromClipboard: () =>
handled.push("updateLastCardFromClipboard"),
triggerFieldGrouping: () => handled.push("triggerFieldGrouping"),
triggerSubsync: () => handled.push("triggerSubsync"),
mineSentence: () => handled.push("mineSentence"),
mineSentenceMultiple: (timeoutMs) =>
handled.push(`mineSentenceMultiple:${timeoutMs}`),
},
);
assert.equal(result, true);
assert.deepEqual(handled, ["copySubtitleMultiple:4321"]);
assert.deepEqual(matched, [{ accelerator: "Ctrl+M", allowWhenRegistered: false }]);
});
test("runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-sub toggle", () => {
const matched: Array<{ accelerator: string; allowWhenRegistered: boolean }> = [];
const shortcuts = makeShortcuts({
toggleSecondarySub: "Ctrl+2",
});
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
(_input, accelerator, allowWhenRegistered) => {
matched.push({
accelerator,
allowWhenRegistered: allowWhenRegistered === true,
});
return accelerator === "Ctrl+2";
},
{
openRuntimeOptions: () => {},
openJimaku: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
toggleSecondarySub: () => {},
updateLastCardFromClipboard: () => {},
triggerFieldGrouping: () => {},
triggerSubsync: () => {},
mineSentence: () => {},
mineSentenceMultiple: () => {},
},
);
assert.equal(result, true);
assert.deepEqual(matched, [{ accelerator: "Ctrl+2", allowWhenRegistered: true }]);
});
test("runOverlayShortcutLocalFallback returns false when no action matches", () => {
const shortcuts = makeShortcuts({
copySubtitle: "Ctrl+C",
});
let called = false;
const result = runOverlayShortcutLocalFallback(
{} as Electron.Input,
shortcuts,
() => false,
{
openRuntimeOptions: () => {
called = true;
},
openJimaku: () => {
called = true;
},
markAudioCard: () => {
called = true;
},
copySubtitleMultiple: () => {
called = true;
},
copySubtitle: () => {
called = true;
},
toggleSecondarySub: () => {
called = true;
},
updateLastCardFromClipboard: () => {
called = true;
},
triggerFieldGrouping: () => {
called = true;
},
triggerSubsync: () => {
called = true;
},
mineSentence: () => {
called = true;
},
mineSentenceMultiple: () => {
called = true;
},
},
);
assert.equal(result, false);
assert.equal(called, false);
});

View File

@@ -80,7 +80,20 @@ export function loadSubtitlePositionService(options: {
typeof parsed.yPercent === "number" &&
Number.isFinite(parsed.yPercent)
) {
return { yPercent: parsed.yPercent };
const position: SubtitlePosition = { yPercent: parsed.yPercent };
if (
typeof parsed.invisibleOffsetXPx === "number" &&
Number.isFinite(parsed.invisibleOffsetXPx)
) {
position.invisibleOffsetXPx = parsed.invisibleOffsetXPx;
}
if (
typeof parsed.invisibleOffsetYPx === "number" &&
Number.isFinite(parsed.invisibleOffsetYPx)
) {
position.invisibleOffsetYPx = parsed.invisibleOffsetYPx;
}
return position;
}
return options.fallbackPosition;
} catch (err) {