diff --git a/src/anki-integration.ts b/src/anki-integration.ts index b7b578d..20876cf 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -20,7 +20,6 @@ import { AnkiConnectClient } from "./anki-connect"; import { SubtitleTimingTracker } from "./subtitle-timing-tracker"; import { MediaGenerator } from "./media-generator"; import * as path from "path"; -import axios from "axios"; import { AnkiConnectConfig, KikuDuplicateCardInfo, @@ -31,6 +30,11 @@ import { } from "./types"; import { DEFAULT_ANKI_CONNECT_CONFIG } from "./config"; import { createLogger } from "./logger"; +import { + AiTranslateCallbacks, + AiTranslateRequest, + translateSentenceWithAi, +} from "./anki-integration/ai"; const log = createLogger("anki").child("integration"); @@ -134,93 +138,6 @@ export class AnkiIntegration { this.fieldGroupingCallback = fieldGroupingCallback || null; } - private extractAiText(content: unknown): string { - if (typeof content === "string") { - return content.trim(); - } - if (!Array.isArray(content)) { - return ""; - } - const parts: string[] = []; - for (const item of content) { - if ( - item && - typeof item === "object" && - "type" in item && - (item as { type?: unknown }).type === "text" && - "text" in item && - typeof (item as { text?: unknown }).text === "string" - ) { - parts.push((item as { text: string }).text); - } - } - return parts.join("").trim(); - } - - private normalizeOpenAiBaseUrl(baseUrl: string): string { - const trimmed = baseUrl.trim().replace(/\/+$/, ""); - if (/\/v1$/i.test(trimmed)) { - return trimmed; - } - return `${trimmed}/v1`; - } - - private async translateSentenceWithAi( - sentence: string, - ): Promise { - const ai = this.config.ai ?? DEFAULT_ANKI_CONNECT_CONFIG.ai; - if (!ai) { - return null; - } - const apiKey = ai?.apiKey?.trim(); - if (!apiKey) { - return null; - } - - const baseUrl = this.normalizeOpenAiBaseUrl( - ai.baseUrl || "https://openrouter.ai/api", - ); - const model = ai.model || "openai/gpt-4o-mini"; - const targetLanguage = ai.targetLanguage || "English"; - const defaultSystemPrompt = - "You are a translation engine. Return only the translated text with no explanations."; - const systemPrompt = ai.systemPrompt?.trim() || defaultSystemPrompt; - - try { - const response = await axios.post( - `${baseUrl}/chat/completions`, - { - model, - temperature: 0, - messages: [ - { role: "system", content: systemPrompt }, - { - role: "user", - content: `Translate this text to ${targetLanguage}:\n\n${sentence}`, - }, - ], - }, - { - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - timeout: 15000, - }, - ); - const content = (response.data as { choices?: unknown[] })?.choices?.[0] as - | { message?: { content?: unknown } } - | undefined; - const translated = this.extractAiText(content?.message?.content); - return translated || null; - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown translation error"; - log.warn("AI translation failed:", message); - return null; - } - } - private getLapisConfig(): { enabled: boolean; sentenceCardModel?: string; @@ -1528,7 +1445,18 @@ export class AnkiIntegration { const shouldAttemptAiTranslation = aiEnabled && (alwaysUseAiTranslation || !hasSecondarySub); if (shouldAttemptAiTranslation) { - const translated = await this.translateSentenceWithAi(sentence); + const request: AiTranslateRequest = { + sentence, + apiKey: aiConfig?.apiKey || "", + baseUrl: aiConfig?.baseUrl, + model: aiConfig?.model, + targetLanguage: aiConfig?.targetLanguage, + systemPrompt: aiConfig?.systemPrompt, + }; + const callbacks: AiTranslateCallbacks = { + logWarning: (message: string) => log.warn(message), + }; + const translated = await translateSentenceWithAi(request, callbacks); if (translated) { backText = translated; } else if (!hasSecondarySub) { diff --git a/src/anki-integration/ai.ts b/src/anki-integration/ai.ts new file mode 100644 index 0000000..ee8b004 --- /dev/null +++ b/src/anki-integration/ai.ts @@ -0,0 +1,103 @@ +import axios from "axios"; + +import { DEFAULT_ANKI_CONNECT_CONFIG } from "../config"; + +const DEFAULT_AI_SYSTEM_PROMPT = + "You are a translation engine. Return only the translated text with no explanations."; + +export function extractAiText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + if (!Array.isArray(content)) { + return ""; + } + + const parts: string[] = []; + for (const item of content) { + if ( + item && + typeof item === "object" && + "type" in item && + (item as { type?: unknown }).type === "text" && + "text" in item && + typeof (item as { text?: unknown }).text === "string" + ) { + parts.push((item as { text: string }).text); + } + } + + return parts.join("").trim(); +} + +export function normalizeOpenAiBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + if (/\/v1$/i.test(trimmed)) { + return trimmed; + } + return `${trimmed}/v1`; +} + +export interface AiTranslateRequest { + sentence: string; + apiKey: string; + baseUrl?: string; + model?: string; + targetLanguage?: string; + systemPrompt?: string; +} + +export interface AiTranslateCallbacks { + logWarning: (message: string) => void; +} + +export async function translateSentenceWithAi( + request: AiTranslateRequest, + callbacks: AiTranslateCallbacks, +): Promise { + const aiConfig = DEFAULT_ANKI_CONNECT_CONFIG.ai; + if (!request.apiKey.trim()) { + return null; + } + + const baseUrl = normalizeOpenAiBaseUrl( + request.baseUrl || aiConfig.baseUrl || "https://openrouter.ai/api", + ); + const model = request.model || "openai/gpt-4o-mini"; + const targetLanguage = request.targetLanguage || "English"; + const prompt = + request.systemPrompt?.trim() || DEFAULT_AI_SYSTEM_PROMPT; + + try { + const response = await axios.post( + `${baseUrl}/chat/completions`, + { + model, + temperature: 0, + messages: [ + { role: "system", content: prompt }, + { + role: "user", + content: `Translate this text to ${targetLanguage}:\n\n${request.sentence}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${request.apiKey}`, + "Content-Type": "application/json", + }, + timeout: 15000, + }, + ); + const content = (response.data as { choices?: unknown[] })?.choices?.[0] as + | { message?: { content?: unknown } } + | undefined; + return extractAiText(content?.message?.content) || null; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown translation error"; + callbacks.logWarning(`AI translation failed: ${message}`); + return null; + } +} diff --git a/src/main.ts b/src/main.ts index d03d5d0..e6d6279 100644 --- a/src/main.ts +++ b/src/main.ts @@ -94,7 +94,6 @@ import { createFieldGroupingOverlayRuntimeService, createNumericShortcutRuntimeService, createOverlayContentMeasurementStoreService, - createOverlayShortcutRuntimeHandlers, createOverlayWindowService, createTokenizerDepsRuntimeService, cycleSecondarySubModeService, @@ -116,9 +115,7 @@ import { mineSentenceCardService, openYomitanSettingsWindow, playNextSubtitleRuntimeService, - refreshOverlayShortcutsRuntimeService, registerGlobalShortcutsService, - registerOverlayShortcutsService, replayCurrentSubtitleRuntimeService, resolveJimakuApiKeyService, runStartupBootstrapRuntimeService, @@ -130,34 +127,31 @@ import { setVisibleOverlayVisibleService, shouldAutoInitializeOverlayRuntimeFromConfigService, shouldBindVisibleOverlayToMpvSubVisibilityService, - shortcutMatchesInputForLocalFallback, showMpvOsdRuntimeService, startAppLifecycleService, syncInvisibleOverlayMousePassthroughService, - syncOverlayShortcutsRuntimeService, tokenizeSubtitleService, triggerFieldGroupingService, - unregisterOverlayShortcutsRuntimeService, updateCurrentMediaPathService, updateInvisibleOverlayVisibilityService, updateLastCardFromClipboardService, updateVisibleOverlayVisibilityService, } from "./core/services"; -import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler"; import { runAppReadyRuntimeService, } from "./core/services/startup-service"; -import type { AppReadyRuntimeDeps } from "./core/services/startup-service"; import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service"; import { - createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder, - createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder, + createAppLifecycleRuntimeDeps, + createAppReadyRuntimeDeps, } from "./main/app-lifecycle"; import { handleMpvCommandFromIpcRuntime } from "./main/ipc-mpv-command"; import { registerIpcRuntimeServices, } from "./main/ipc-runtime"; -import { handleCliCommandRuntimeService } from "./main/cli-runtime"; +import { + handleCliCommandRuntimeServiceWithContext, +} from "./main/cli-runtime"; import { runSubsyncManualFromIpcRuntime, triggerSubsyncFromConfigRuntime, @@ -166,6 +160,9 @@ import { createOverlayModalRuntimeService, type OverlayHostedModal, } from "./main/overlay-runtime"; +import { + createOverlayShortcutsRuntimeService, +} from "./main/overlay-shortcuts-runtime"; import { applyStartupState, createAppState, @@ -177,9 +174,6 @@ import { DEFAULT_KEYBINDINGS, generateConfigTemplate, } from "./config"; -import type { - AppLifecycleDepsRuntimeOptions, -} from "./core/services/app-lifecycle-service"; if (process.platform === "linux") { app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal"); @@ -290,6 +284,44 @@ const appState = createAppState({ mpvSocketPath: getDefaultSocketPath(), texthookerPort: DEFAULT_TEXTHOOKER_PORT, }); +const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService({ + getConfiguredShortcuts: () => getConfiguredShortcuts(), + getShortcutsRegistered: () => appState.shortcutsRegistered, + setShortcutsRegistered: (registered) => { + appState.shortcutsRegistered = registered; + }, + isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized, + showMpvOsd: (text: string) => showMpvOsd(text), + openRuntimeOptionsPalette: () => { + openRuntimeOptionsPalette(); + }, + openJimaku: () => { + sendToActiveOverlayWindow("jimaku:open", undefined, { + restoreOnModalClose: "jimaku", + }); + }, + markAudioCard: () => markLastCardAsAudioCard(), + copySubtitleMultiple: (timeoutMs) => { + startPendingMultiCopy(timeoutMs); + }, + copySubtitle: () => { + copyCurrentSubtitle(); + }, + toggleSecondarySubMode: () => cycleSecondarySubMode(), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceMultiple: (timeoutMs) => { + startPendingMineSentenceMultiple(timeoutMs); + }, + cancelPendingMultiCopy: () => { + cancelPendingMultiCopy(); + }, + cancelPendingMineSentenceMultiple: () => { + cancelPendingMineSentenceMultiple(); + }, +}); function getFieldGroupingResolver(): ((choice: KikuFieldGroupingChoice) => void) | null { return appState.fieldGroupingResolver; @@ -530,7 +562,125 @@ const startupState = runStartupBootstrapRuntimeService( startAppLifecycle: (args: CliArgs) => { startAppLifecycleService( args, - createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()), + createAppLifecycleDepsRuntimeService( + createAppLifecycleRuntimeDeps({ + app, + platform: process.platform, + shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), + parseArgs: (argv: string[]) => parseArgs(argv), + handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => + handleCliCommand(nextArgs, source), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + logNoRunningInstance: () => appLogger.logNoRunningInstance(), + onReady: async () => { + await runAppReadyRuntimeService( + createAppReadyRuntimeDeps({ + loadSubtitlePosition: () => loadSubtitlePosition(), + resolveKeybindings: () => { + appState.keybindings = resolveKeybindings( + getResolvedConfig(), + DEFAULT_KEYBINDINGS, + ); + }, + createMpvClient: () => { + appState.mpvClient = createMpvClientRuntimeService(); + }, + reloadConfig: () => { + configService.reloadConfig(); + appLogger.logInfo( + `Using config file: ${configService.getConfigPath()}`, + ); + }, + getResolvedConfig: () => getResolvedConfig(), + getConfigWarnings: () => configService.getWarnings(), + logConfigWarning: (warning) => appLogger.logConfigWarning(warning), + initRuntimeOptionsManager: () => { + appState.runtimeOptionsManager = new RuntimeOptionsManager( + () => configService.getConfig().ankiConnect, + { + applyAnkiPatch: (patch) => { + if (appState.ankiIntegration) { + appState.ankiIntegration.applyRuntimeConfigPatch(patch); + } + }, + onOptionsChanged: () => { + broadcastRuntimeOptionsChanged(); + refreshOverlayShortcuts(); + }, + }, + ); + }, + setSecondarySubMode: (mode: SecondarySubMode) => { + appState.secondarySubMode = mode; + }, + defaultSecondarySubMode: "hover", + defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, + hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), + startSubtitleWebsocket: (port: number) => { + subtitleWsService.start(port, () => appState.currentSubText); + }, + log: (message) => appLogger.logInfo(message), + createMecabTokenizerAndCheck: async () => { + const tokenizer = new MecabTokenizer(); + appState.mecabTokenizer = tokenizer; + await tokenizer.checkAvailability(); + }, + createSubtitleTimingTracker: () => { + const tracker = new SubtitleTimingTracker(); + appState.subtitleTimingTracker = tracker; + }, + loadYomitanExtension: async () => { + await loadYomitanExtension(); + }, + texthookerOnlyMode: appState.texthookerOnlyMode, + shouldAutoInitializeOverlayRuntimeFromConfig: () => + shouldAutoInitializeOverlayRuntimeFromConfig(), + initializeOverlayRuntime: () => initializeOverlayRuntime(), + handleInitialArgs: () => handleInitialArgs(), + }), + ); + }, + onWillQuitCleanup: () => { + restorePreviousSecondarySubVisibility(); + globalShortcut.unregisterAll(); + subtitleWsService.stop(); + texthookerService.stop(); + if ( + appState.yomitanParserWindow && + !appState.yomitanParserWindow.isDestroyed() + ) { + appState.yomitanParserWindow.destroy(); + } + appState.yomitanParserWindow = null; + appState.yomitanParserReadyPromise = null; + appState.yomitanParserInitPromise = null; + if (appState.windowTracker) { + appState.windowTracker.stop(); + } + if (appState.mpvClient && appState.mpvClient.socket) { + appState.mpvClient.socket.destroy(); + } + if (appState.reconnectTimer) { + clearTimeout(appState.reconnectTimer); + } + if (appState.subtitleTimingTracker) { + appState.subtitleTimingTracker.destroy(); + } + if (appState.ankiIntegration) { + appState.ankiIntegration.destroy(); + } + }, + shouldRestoreWindowsOnActivate: () => + appState.overlayRuntimeInitialized && + BrowserWindow.getAllWindows().length === 0, + restoreWindowsOnActivate: () => { + createMainWindow(); + createInvisibleWindow(); + updateVisibleOverlayVisibility(); + updateInvisibleOverlayVisibility(); + }, + }), + ), ); }, }), @@ -538,176 +688,49 @@ const startupState = runStartupBootstrapRuntimeService( applyStartupState(appState, startupState); -function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions { - return createAppLifecycleRuntimeDepsBuilder({ - app, - platform: process.platform, - shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), - parseArgs: (argv: string[]) => parseArgs(argv), - handleCliCommand: ( - nextArgs: CliArgs, - source: CliCommandSource, - ) => handleCliCommand(nextArgs, source), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - logNoRunningInstance: () => appLogger.logNoRunningInstance(), - onReady: async () => { - await runAppReadyRuntimeService(createAppReadyRuntimeDeps()); - }, - onWillQuitCleanup: () => { - restorePreviousSecondarySubVisibility(); - globalShortcut.unregisterAll(); - subtitleWsService.stop(); - texthookerService.stop(); - if (appState.yomitanParserWindow && !appState.yomitanParserWindow.isDestroyed()) { - appState.yomitanParserWindow.destroy(); - } - appState.yomitanParserWindow = null; - appState.yomitanParserReadyPromise = null; - appState.yomitanParserInitPromise = null; - if (appState.windowTracker) { - appState.windowTracker.stop(); - } - if (appState.mpvClient && appState.mpvClient.socket) { - appState.mpvClient.socket.destroy(); - } - if (appState.reconnectTimer) { - clearTimeout(appState.reconnectTimer); - } - if (appState.subtitleTimingTracker) { - appState.subtitleTimingTracker.destroy(); - } - if (appState.ankiIntegration) { - appState.ankiIntegration.destroy(); - } - }, - shouldRestoreWindowsOnActivate: () => - appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, - restoreWindowsOnActivate: () => { - createMainWindow(); - createInvisibleWindow(); - updateVisibleOverlayVisibility(); - updateInvisibleOverlayVisibility(); - }, - }); -} - -function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps { - return createAppReadyRuntimeDepsBuilder({ - loadSubtitlePosition: () => loadSubtitlePosition(), - resolveKeybindings: () => { - appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); - }, - createMpvClient: () => { - appState.mpvClient = createMpvClientRuntimeService(); - }, - reloadConfig: () => { - configService.reloadConfig(); - appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); - }, - getResolvedConfig: () => getResolvedConfig(), - getConfigWarnings: () => configService.getWarnings(), - logConfigWarning: (warning) => appLogger.logConfigWarning(warning), - initRuntimeOptionsManager: () => { - appState.runtimeOptionsManager = new RuntimeOptionsManager( - () => configService.getConfig().ankiConnect, - { - applyAnkiPatch: (patch) => { - if (appState.ankiIntegration) { - appState.ankiIntegration.applyRuntimeConfigPatch(patch); - } - }, - onOptionsChanged: () => { - broadcastRuntimeOptionsChanged(); - refreshOverlayShortcuts(); - }, - }, - ); - }, - setSecondarySubMode: (mode: SecondarySubMode) => { - appState.secondarySubMode = mode; - }, - defaultSecondarySubMode: "hover", - defaultWebsocketPort: DEFAULT_CONFIG.websocket.port, - hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(), - startSubtitleWebsocket: (port: number) => { - subtitleWsService.start(port, () => appState.currentSubText); - }, - log: (message) => appLogger.logInfo(message), - createMecabTokenizerAndCheck: async () => { - const tokenizer = new MecabTokenizer(); - appState.mecabTokenizer = tokenizer; - await tokenizer.checkAvailability(); - }, - createSubtitleTimingTracker: () => { - const tracker = new SubtitleTimingTracker(); - appState.subtitleTimingTracker = tracker; - }, - loadYomitanExtension: async () => { - await loadYomitanExtension(); - }, - texthookerOnlyMode: appState.texthookerOnlyMode, - shouldAutoInitializeOverlayRuntimeFromConfig: () => - shouldAutoInitializeOverlayRuntimeFromConfig(), - initializeOverlayRuntime: () => initializeOverlayRuntime(), - handleInitialArgs: () => handleInitialArgs(), - }); -} - function handleCliCommand( args: CliArgs, source: CliCommandSource = "initial", ): void { - handleCliCommandRuntimeService(args, source, { - mpv: { - getSocketPath: () => appState.mpvSocketPath, - setSocketPath: (socketPath: string) => { - appState.mpvSocketPath = socketPath; - }, - getClient: () => appState.mpvClient, - showOsd: (text: string) => showMpvOsd(text), + handleCliCommandRuntimeServiceWithContext(args, source, { + getSocketPath: () => appState.mpvSocketPath, + setSocketPath: (socketPath: string) => { + appState.mpvSocketPath = socketPath; }, - texthooker: { - service: texthookerService, - getPort: () => appState.texthookerPort, - setPort: (port: number) => { - appState.texthookerPort = port; - }, - shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, - openInBrowser: (url: string) => { - void shell.openExternal(url).catch((error) => { - console.error(`Failed to open browser for texthooker URL: ${url}`, error); - }); - }, + getClient: () => appState.mpvClient, + showOsd: (text: string) => showMpvOsd(text), + texthookerService, + getTexthookerPort: () => appState.texthookerPort, + setTexthookerPort: (port: number) => { + appState.texthookerPort = port; }, - overlay: { - isInitialized: () => appState.overlayRuntimeInitialized, - initialize: () => initializeOverlayRuntime(), - toggleVisible: () => toggleVisibleOverlay(), - toggleInvisible: () => toggleInvisibleOverlay(), - setVisible: (visible: boolean) => setVisibleOverlayVisible(visible), - setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible), - }, - mining: { - copyCurrentSubtitle: () => copyCurrentSubtitle(), - startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), - mineSentenceCard: () => mineSentenceCard(), - startPendingMineSentenceMultiple: (timeoutMs: number) => - startPendingMineSentenceMultiple(timeoutMs), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), - markLastCardAsAudioCard: () => markLastCardAsAudioCard(), - }, - ui: { - openYomitanSettings: () => openYomitanSettings(), - cycleSecondarySubMode: () => cycleSecondarySubMode(), - openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), - }, - app: { - stop: () => app.quit(), - hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false, + openInBrowser: (url: string) => { + void shell.openExternal(url).catch((error) => { + console.error(`Failed to open browser for texthooker URL: ${url}`, error); + }); }, + isOverlayInitialized: () => appState.overlayRuntimeInitialized, + initializeOverlay: () => initializeOverlayRuntime(), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + toggleInvisibleOverlay: () => toggleInvisibleOverlay(), + setVisibleOverlay: (visible: boolean) => setVisibleOverlayVisible(visible), + setInvisibleOverlay: (visible: boolean) => setInvisibleOverlayVisible(visible), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), + mineSentenceCard: () => mineSentenceCard(), + startPendingMineSentenceMultiple: (timeoutMs: number) => + startPendingMineSentenceMultiple(timeoutMs), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + openYomitanSettings: () => openYomitanSettings(), + cycleSecondarySubMode: () => cycleSecondarySubMode(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), + stopApp: () => app.quit(), + hasMainWindow: () => Boolean(overlayManager.getMainWindow()), getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), log: (message: string) => { @@ -875,7 +898,7 @@ function createOverlayWindow(kind: "visible" | "invisible"): BrowserWindow { ? overlayManager.getVisibleOverlayVisible() : overlayManager.getInvisibleOverlayVisible(), tryHandleOverlayShortcutLocalFallback: (input) => - tryHandleOverlayShortcutLocalFallback(input), + overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), onWindowClosed: (windowKind) => { if (windowKind === "visible") { overlayManager.setMainWindow(null); @@ -932,9 +955,7 @@ function initializeOverlayRuntime(): void { updateInvisibleOverlayVisibility(); }, getOverlayWindows: () => getOverlayWindows(), - syncOverlayShortcuts: () => { - syncOverlayShortcuts(); - }, + syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), setWindowTracker: (tracker) => { appState.windowTracker = tracker; }, @@ -980,46 +1001,6 @@ function registerGlobalShortcuts(): void { function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolvedConfig(), DEFAULT_CONFIG); } -function getOverlayShortcutRuntimeHandlers() { - return createOverlayShortcutRuntimeHandlers( - { - showMpvOsd: (text: string) => showMpvOsd(text), - openRuntimeOptions: () => { - openRuntimeOptionsPalette(); - }, - openJimaku: () => { - sendToActiveOverlayWindow("jimaku:open", undefined, { - restoreOnModalClose: "jimaku", - }); - }, - markAudioCard: () => markLastCardAsAudioCard(), - copySubtitleMultiple: (timeoutMs: number) => { - startPendingMultiCopy(timeoutMs); - }, - copySubtitle: () => { - copyCurrentSubtitle(); - }, - toggleSecondarySub: () => cycleSecondarySubMode(), - updateLastCardFromClipboard: () => updateLastCardFromClipboard(), - triggerFieldGrouping: () => triggerFieldGrouping(), - triggerSubsync: () => triggerSubsyncFromConfig(), - mineSentence: () => mineSentenceCard(), - mineSentenceMultiple: (timeoutMs: number) => { - startPendingMineSentenceMultiple(timeoutMs); - }, - }, - ); -} - -function tryHandleOverlayShortcutLocalFallback(input: Electron.Input): boolean { - return runOverlayShortcutLocalFallback( - input, - getConfiguredShortcuts(), - shortcutMatchesInputForLocalFallback, - getOverlayShortcutRuntimeHandlers().fallbackHandlers, - ); -} - function cycleSecondarySubMode(): void { cycleSecondarySubModeService( { @@ -1201,42 +1182,18 @@ function handleMineSentenceDigit(count: number): void { } function registerOverlayShortcuts(): void { - appState.shortcutsRegistered = registerOverlayShortcutsService( - getConfiguredShortcuts(), - getOverlayShortcutRuntimeHandlers().overlayHandlers, - ); -} - -function getOverlayShortcutLifecycleDeps() { - return { - getConfiguredShortcuts: () => getConfiguredShortcuts(), - getOverlayHandlers: () => getOverlayShortcutRuntimeHandlers().overlayHandlers, - cancelPendingMultiCopy: () => cancelPendingMultiCopy(), - cancelPendingMineSentenceMultiple: () => cancelPendingMineSentenceMultiple(), - }; + overlayShortcutsRuntime.registerOverlayShortcuts(); } function unregisterOverlayShortcuts(): void { - appState.shortcutsRegistered = unregisterOverlayShortcutsRuntimeService( - appState.shortcutsRegistered, - getOverlayShortcutLifecycleDeps(), - ); + overlayShortcutsRuntime.unregisterOverlayShortcuts(); } -function shouldOverlayShortcutsBeActive(): boolean { return appState.overlayRuntimeInitialized; } function syncOverlayShortcuts(): void { - appState.shortcutsRegistered = syncOverlayShortcutsRuntimeService( - shouldOverlayShortcutsBeActive(), - appState.shortcutsRegistered, - getOverlayShortcutLifecycleDeps(), - ); + overlayShortcutsRuntime.syncOverlayShortcuts(); } function refreshOverlayShortcuts(): void { - appState.shortcutsRegistered = refreshOverlayShortcutsRuntimeService( - shouldOverlayShortcutsBeActive(), - appState.shortcutsRegistered, - getOverlayShortcutLifecycleDeps(), - ); + overlayShortcutsRuntime.refreshOverlayShortcuts(); } function updateVisibleOverlayVisibility(): void { diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 2dd3a4f..139c964 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -2,6 +2,99 @@ import { handleCliCommandService, createCliCommandDepsRuntimeService } from "../ import type { CliArgs, CliCommandSource } from "../cli/args"; import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams } from "./dependencies"; +export interface CliCommandRuntimeServiceContext { + getSocketPath: () => string; + setSocketPath: (socketPath: string) => void; + getClient: CliCommandRuntimeServiceDepsParams["mpv"]["getClient"]; + showOsd: CliCommandRuntimeServiceDepsParams["mpv"]["showOsd"]; + getTexthookerPort: () => number; + setTexthookerPort: (port: number) => void; + shouldOpenBrowser: () => boolean; + openInBrowser: (url: string) => void; + isOverlayInitialized: () => boolean; + initializeOverlay: () => void; + toggleVisibleOverlay: () => void; + toggleInvisibleOverlay: () => void; + setVisibleOverlay: (visible: boolean) => void; + setInvisibleOverlay: (visible: boolean) => void; + copyCurrentSubtitle: () => void; + startPendingMultiCopy: (timeoutMs: number) => void; + mineSentenceCard: () => Promise; + startPendingMineSentenceMultiple: (timeoutMs: number) => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + markLastCardAsAudioCard: () => Promise; + openYomitanSettings: () => void; + cycleSecondarySubMode: () => void; + openRuntimeOptionsPalette: () => void; + printHelp: () => void; + stopApp: () => void; + hasMainWindow: () => boolean; + getMultiCopyTimeoutMs: () => number; + schedule: (fn: () => void, delayMs: number) => ReturnType; + log: (message: string) => void; + warn: (message: string) => void; + error: (message: string, err: unknown) => void; +} + +export interface CliCommandRuntimeServiceContextHandlers { + texthookerService: CliCommandRuntimeServiceDepsParams["texthooker"]["service"]; +} + +function createCliCommandDepsFromContext( + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, +): CliCommandRuntimeServiceDepsParams { + return { + mpv: { + getSocketPath: context.getSocketPath, + setSocketPath: context.setSocketPath, + getClient: context.getClient, + showOsd: context.showOsd, + }, + texthooker: { + service: context.texthookerService, + getPort: context.getTexthookerPort, + setPort: context.setTexthookerPort, + shouldOpenBrowser: context.shouldOpenBrowser, + openInBrowser: context.openInBrowser, + }, + overlay: { + isInitialized: context.isOverlayInitialized, + initialize: context.initializeOverlay, + toggleVisible: context.toggleVisibleOverlay, + toggleInvisible: context.toggleInvisibleOverlay, + setVisible: context.setVisibleOverlay, + setInvisible: context.setInvisibleOverlay, + }, + mining: { + copyCurrentSubtitle: context.copyCurrentSubtitle, + startPendingMultiCopy: context.startPendingMultiCopy, + mineSentenceCard: context.mineSentenceCard, + startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: context.updateLastCardFromClipboard, + triggerFieldGrouping: context.triggerFieldGrouping, + triggerSubsyncFromConfig: context.triggerSubsyncFromConfig, + markLastCardAsAudioCard: context.markLastCardAsAudioCard, + }, + ui: { + openYomitanSettings: context.openYomitanSettings, + cycleSecondarySubMode: context.cycleSecondarySubMode, + openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, + printHelp: context.printHelp, + }, + app: { + stop: context.stopApp, + hasMainWindow: context.hasMainWindow, + }, + getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, + schedule: context.schedule, + log: context.log, + warn: context.warn, + error: context.error, + }; +} + export function handleCliCommandRuntimeService( args: CliArgs, source: CliCommandSource, @@ -13,3 +106,10 @@ export function handleCliCommandRuntimeService( handleCliCommandService(args, source, deps); } +export function handleCliCommandRuntimeServiceWithContext( + args: CliArgs, + source: CliCommandSource, + context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers, +): void { + handleCliCommandRuntimeService(args, source, createCliCommandDepsFromContext(context)); +} diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts new file mode 100644 index 0000000..7f9b1df --- /dev/null +++ b/src/main/overlay-shortcuts-runtime.ts @@ -0,0 +1,138 @@ +import type { ConfiguredShortcuts } from "../core/utils/shortcut-config"; +import { + createOverlayShortcutRuntimeHandlers, + shortcutMatchesInputForLocalFallback, +} from "../core/services"; +import { + refreshOverlayShortcutsRuntimeService, + registerOverlayShortcutsService, + syncOverlayShortcutsRuntimeService, + unregisterOverlayShortcutsRuntimeService, +} from "../core/services"; +import { runOverlayShortcutLocalFallback } from "../core/services/overlay-shortcut-handler"; + +export interface OverlayShortcutRuntimeServiceInput { + getConfiguredShortcuts: () => ConfiguredShortcuts; + getShortcutsRegistered: () => boolean; + setShortcutsRegistered: (registered: boolean) => void; + isOverlayRuntimeInitialized: () => boolean; + showMpvOsd: (text: string) => void; + openRuntimeOptionsPalette: () => void; + openJimaku: () => void; + markAudioCard: () => Promise; + copySubtitleMultiple: (timeoutMs: number) => void; + copySubtitle: () => void; + toggleSecondarySubMode: () => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + mineSentenceCard: () => Promise; + mineSentenceMultiple: (timeoutMs: number) => void; + cancelPendingMultiCopy: () => void; + cancelPendingMineSentenceMultiple: () => void; +} + +export interface OverlayShortcutsRuntimeService { + tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; + registerOverlayShortcuts: () => void; + unregisterOverlayShortcuts: () => void; + syncOverlayShortcuts: () => void; + refreshOverlayShortcuts: () => void; +} + +export function createOverlayShortcutsRuntimeService( + input: OverlayShortcutRuntimeServiceInput, +): OverlayShortcutsRuntimeService { + const handlers = createOverlayShortcutRuntimeHandlers({ + showMpvOsd: (text: string) => input.showMpvOsd(text), + openRuntimeOptions: () => { + input.openRuntimeOptionsPalette(); + }, + openJimaku: () => { + input.openJimaku(); + }, + markAudioCard: () => { + return input.markAudioCard(); + }, + copySubtitleMultiple: (timeoutMs: number) => { + input.copySubtitleMultiple(timeoutMs); + }, + copySubtitle: () => { + input.copySubtitle(); + }, + toggleSecondarySub: () => { + input.toggleSecondarySubMode(); + }, + updateLastCardFromClipboard: () => { + return input.updateLastCardFromClipboard(); + }, + triggerFieldGrouping: () => { + return input.triggerFieldGrouping(); + }, + triggerSubsync: () => { + return input.triggerSubsyncFromConfig(); + }, + mineSentence: () => { + return input.mineSentenceCard(); + }, + mineSentenceMultiple: (timeoutMs: number) => { + input.mineSentenceMultiple(timeoutMs); + }, + }); + + const getShortcutLifecycleDeps = () => { + return { + getConfiguredShortcuts: () => input.getConfiguredShortcuts(), + getOverlayHandlers: () => handlers.overlayHandlers, + cancelPendingMultiCopy: () => input.cancelPendingMultiCopy(), + cancelPendingMineSentenceMultiple: () => + input.cancelPendingMineSentenceMultiple(), + }; + }; + + const shouldOverlayShortcutsBeActive = () => input.isOverlayRuntimeInitialized(); + + return { + tryHandleOverlayShortcutLocalFallback: (inputEvent) => + runOverlayShortcutLocalFallback( + inputEvent, + input.getConfiguredShortcuts(), + shortcutMatchesInputForLocalFallback, + handlers.fallbackHandlers, + ), + registerOverlayShortcuts: () => { + input.setShortcutsRegistered( + registerOverlayShortcutsService( + input.getConfiguredShortcuts(), + handlers.overlayHandlers, + ), + ); + }, + unregisterOverlayShortcuts: () => { + input.setShortcutsRegistered( + unregisterOverlayShortcutsRuntimeService( + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + syncOverlayShortcuts: () => { + input.setShortcutsRegistered( + syncOverlayShortcutsRuntimeService( + shouldOverlayShortcutsBeActive(), + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + refreshOverlayShortcuts: () => { + input.setShortcutsRegistered( + refreshOverlayShortcutsRuntimeService( + shouldOverlayShortcutsBeActive(), + input.getShortcutsRegistered(), + getShortcutLifecycleDeps(), + ), + ); + }, + }; +}