import { AnkiIntegration } from "../../anki-integration"; import { AnkiConnectConfig, JimakuApiResponse, JimakuEntry, JimakuFileEntry, JimakuLanguagePreference, JimakuMediaInfo, KikuFieldGroupingChoice, KikuFieldGroupingRequestData, } from "../../types"; import { sortJimakuFiles } from "../../jimaku/utils"; import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc-service"; export type RegisterAnkiJimakuIpcRuntimeHandler = ( deps: AnkiJimakuIpcDeps, ) => void; interface MpvClientLike { connected: boolean; send: (payload: { command: string[] }) => void; } interface RuntimeOptionsManagerLike { getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig; } interface SubtitleTimingTrackerLike { cleanup: () => void; } export interface AnkiJimakuIpcRuntimeOptions { patchAnkiConnectEnabled: (enabled: boolean) => void; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; getMpvClient: () => MpvClientLike | null; getAnkiIntegration: () => AnkiIntegration | null; setAnkiIntegration: (integration: AnkiIntegration | null) => void; getKnownWordCacheStatePath: () => string; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, ) => Promise; broadcastRuntimeOptionsChanged: () => void; getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo; getCurrentMediaPath: () => string | null; jimakuFetchJson: ( endpoint: string, query?: Record, ) => Promise>; getJimakuMaxEntryResults: () => number; getJimakuLanguagePreference: () => JimakuLanguagePreference; resolveJimakuApiKey: () => Promise; isRemoteMediaPath: (mediaPath: string) => boolean; downloadToFile: ( url: string, destPath: string, headers: Record, ) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>; } export function registerAnkiJimakuIpcRuntimeService( options: AnkiJimakuIpcRuntimeOptions, registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler, ): void { registerHandlers({ setAnkiConnectEnabled: (enabled) => { options.patchAnkiConnectEnabled(enabled); const config = options.getResolvedConfig(); const subtitleTimingTracker = options.getSubtitleTimingTracker(); const mpvClient = options.getMpvClient(); const ankiIntegration = options.getAnkiIntegration(); if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) { const runtimeOptionsManager = options.getRuntimeOptionsManager(); const effectiveAnkiConfig = runtimeOptionsManager ? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect) : config.ankiConnect; const integration = new AnkiIntegration( effectiveAnkiConfig as never, subtitleTimingTracker as never, mpvClient as never, (text: string) => { if (mpvClient) { mpvClient.send({ command: ["show-text", text, "3000"], }); } }, options.showDesktopNotification, options.createFieldGroupingCallback(), options.getKnownWordCacheStatePath(), ); integration.start(); options.setAnkiIntegration(integration); console.log("AnkiConnect integration enabled"); } else if (!enabled && ankiIntegration) { ankiIntegration.destroy(); options.setAnkiIntegration(null); console.log("AnkiConnect integration disabled"); } options.broadcastRuntimeOptionsChanged(); }, clearAnkiHistory: () => { const subtitleTimingTracker = options.getSubtitleTimingTracker(); if (subtitleTimingTracker) { subtitleTimingTracker.cleanup(); 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) { resolver(choice); options.setFieldGroupingResolver(null); } }, buildKikuMergePreview: async (request) => { const integration = options.getAnkiIntegration(); if (!integration) { return { ok: false, error: "AnkiConnect integration not enabled" }; } return integration.buildFieldGroupingPreview( request.keepNoteId, request.deleteNoteId, request.deleteDuplicate, ); }, getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()), searchJimakuEntries: async (query) => { console.log(`[jimaku] search-entries query: "${query.query}"`); const response = await options.jimakuFetchJson( "/api/entries/search", { anime: true, query: query.query, }, ); if (!response.ok) return response; const maxResults = options.getJimakuMaxEntryResults(); console.log( `[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`, ); return { ok: true, data: response.data.slice(0, maxResults) }; }, listJimakuFiles: async (query) => { console.log( `[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`, ); const response = await options.jimakuFetchJson( `/api/entries/${query.entryId}/files`, { episode: query.episode ?? undefined, }, ); if (!response.ok) return response; const sorted = sortJimakuFiles( response.data, options.getJimakuLanguagePreference(), ); console.log(`[jimaku] list-files returned ${sorted.length} files`); return { ok: true, data: sorted }; }, resolveJimakuApiKey: () => options.resolveJimakuApiKey(), getCurrentMediaPath: () => options.getCurrentMediaPath(), isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath), downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers), onDownloadedSubtitle: (pathToSubtitle) => { const mpvClient = options.getMpvClient(); if (mpvClient && mpvClient.connected) { mpvClient.send({ command: ["sub-add", pathToSubtitle, "select"] }); } }, }); }