diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 90e0c3d..a955400 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -209,6 +209,24 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', } }); +test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => { + const integration = new AnkiIntegration( + { + enabled: true, + proxy: { + enabled: false, + }, + } as never, + {} as never, + {} as never, + ); + + const privateState = integration as unknown as { + proxyServer: unknown | null; + }; + assert.equal(privateState.proxyServer, null); +}); + test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 1146399..bb08cc2 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -41,6 +41,7 @@ import { } from './anki-integration/ui-feedback'; import { KnownWordCacheManager } from './anki-integration/known-word-cache'; import { PollingRunner } from './anki-integration/polling'; +import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy'; import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate'; import { CardCreationService } from './anki-integration/card-creation'; import { FieldGroupingService } from './anki-integration/field-grouping'; @@ -63,6 +64,8 @@ export class AnkiIntegration { private timingTracker: SubtitleTimingTracker; private config: AnkiConnectConfig; private pollingRunner!: PollingRunner; + private proxyServer: AnkiConnectProxyServer | null = null; + private started = false; private previousNoteIds = new Set(); private mpvClient: MpvClient; private osdCallback: ((text: string) => void) | null = null; @@ -131,13 +134,46 @@ export class AnkiIntegration { } private normalizeConfig(config: AnkiConnectConfig): AnkiConnectConfig { + const resolvedUrl = + typeof config.url === 'string' && config.url.trim().length > 0 + ? config.url.trim() + : DEFAULT_ANKI_CONNECT_CONFIG.url; + const proxySource = + config.proxy && typeof config.proxy === 'object' + ? (config.proxy as NonNullable) + : {}; + const normalizedProxyPort = + typeof proxySource.port === 'number' && + Number.isInteger(proxySource.port) && + proxySource.port >= 1 && + proxySource.port <= 65535 + ? proxySource.port + : DEFAULT_ANKI_CONNECT_CONFIG.proxy?.port; + const normalizedProxyHost = + typeof proxySource.host === 'string' && proxySource.host.trim().length > 0 + ? proxySource.host.trim() + : DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host; + const normalizedProxyUpstreamUrl = + typeof proxySource.upstreamUrl === 'string' && proxySource.upstreamUrl.trim().length > 0 + ? proxySource.upstreamUrl.trim() + : resolvedUrl; + return { ...DEFAULT_ANKI_CONNECT_CONFIG, ...config, + url: resolvedUrl, fields: { ...DEFAULT_ANKI_CONNECT_CONFIG.fields, ...(config.fields ?? {}), }, + proxy: { + ...DEFAULT_ANKI_CONNECT_CONFIG.proxy, + ...(config.proxy ?? {}), + enabled: proxySource.enabled === true, + host: normalizedProxyHost, + port: normalizedProxyPort, + upstreamUrl: normalizedProxyUpstreamUrl, + }, ai: { ...DEFAULT_ANKI_CONNECT_CONFIG.ai, ...(config.openRouter ?? {}), @@ -202,6 +238,24 @@ export class AnkiIntegration { }); } + private createProxyServer(): AnkiConnectProxyServer { + const { AnkiConnectProxyServer } = require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy'); + return new AnkiConnectProxyServer({ + shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, + processNewCard: (noteId: number) => this.processNewCard(noteId), + logInfo: (message, ...args) => log.info(message, ...args), + logWarn: (message, ...args) => log.warn(message, ...args), + logError: (message, ...args) => log.error(message, ...args), + }); + } + + private getOrCreateProxyServer(): AnkiConnectProxyServer { + if (!this.proxyServer) { + this.proxyServer = this.createProxyServer(); + } + return this.proxyServer; + } + private createCardCreationService(): CardCreationService { return new CardCreationService({ getConfig: () => this.config, @@ -499,19 +553,63 @@ export class AnkiIntegration { }; } - start(): void { - if (this.pollingRunner.isRunning) { - this.stop(); + private isProxyTransportEnabled(config: AnkiConnectConfig = this.config): boolean { + return config.proxy?.enabled === true; + } + + private getTransportConfigKey(config: AnkiConnectConfig = this.config): string { + if (this.isProxyTransportEnabled(config)) { + return [ + 'proxy', + config.proxy?.host ?? '', + String(config.proxy?.port ?? ''), + config.proxy?.upstreamUrl ?? '', + ].join(':'); + } + return ['polling', String(config.pollingRate ?? DEFAULT_ANKI_CONNECT_CONFIG.pollingRate)].join( + ':', + ); + } + + private startTransport(): void { + if (this.isProxyTransportEnabled()) { + const proxyHost = this.config.proxy?.host ?? '127.0.0.1'; + const proxyPort = this.config.proxy?.port ?? 8766; + const upstreamUrl = this.config.proxy?.upstreamUrl ?? this.config.url ?? ''; + this.getOrCreateProxyServer().start({ + host: proxyHost, + port: proxyPort, + upstreamUrl, + }); + log.info( + `Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`, + ); + return; } log.info('Starting AnkiConnect integration with polling rate:', this.config.pollingRate); - this.startKnownWordCacheLifecycle(); this.pollingRunner.start(); } - stop(): void { + private stopTransport(): void { this.pollingRunner.stop(); + this.proxyServer?.stop(); + } + + start(): void { + if (this.started) { + this.stop(); + } + + this.startKnownWordCacheLifecycle(); + this.startTransport(); + this.started = true; + } + + stop(): void { + this.stopTransport(); this.stopKnownWordCacheLifecycle(); + this.started = false; log.info('Stopped AnkiConnect integration'); } @@ -1062,8 +1160,9 @@ export class AnkiIntegration { applyRuntimeConfigPatch(patch: Partial): void { const wasEnabled = this.config.nPlusOne?.highlightEnabled === true; - const previousPollingRate = this.config.pollingRate; - this.config = { + const previousTransportKey = this.getTransportConfigKey(this.config); + + const mergedConfig: AnkiConnectConfig = { ...this.config, ...patch, nPlusOne: @@ -1083,6 +1182,8 @@ export class AnkiIntegration { patch.behavior !== undefined ? { ...this.config.behavior, ...patch.behavior } : this.config.behavior, + proxy: + patch.proxy !== undefined ? { ...this.config.proxy, ...patch.proxy } : this.config.proxy, metadata: patch.metadata !== undefined ? { ...this.config.metadata, ...patch.metadata } @@ -1096,6 +1197,7 @@ export class AnkiIntegration { ? { ...this.config.isKiku, ...patch.isKiku } : this.config.isKiku, }; + this.config = this.normalizeConfig(mergedConfig); if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) { this.stopKnownWordCacheLifecycle(); @@ -1104,12 +1206,10 @@ export class AnkiIntegration { this.startKnownWordCacheLifecycle(); } - if ( - patch.pollingRate !== undefined && - previousPollingRate !== this.config.pollingRate && - this.pollingRunner.isRunning - ) { - this.pollingRunner.start(); + const nextTransportKey = this.getTransportConfigKey(this.config); + if (this.started && previousTransportKey !== nextTransportKey) { + this.stopTransport(); + this.startTransport(); } } diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts new file mode 100644 index 0000000..946e278 --- /dev/null +++ b/src/core/services/overlay-runtime-init.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { initializeOverlayRuntime } from './overlay-runtime-init'; + +test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => { + let createdIntegrations = 0; + let startedIntegrations = 0; + let setIntegrationCalls = 0; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => false, + updateVisibleOverlayVisibility: () => {}, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => null, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => ({}), + getMpvClient: () => ({ + send: () => {}, + }), + getRuntimeOptionsManager: () => ({ + getEffectiveAnkiConnectConfig: (config) => config as never, + }), + createAnkiIntegration: () => { + createdIntegrations += 1; + return { + start: () => { + startedIntegrations += 1; + }, + }; + }, + setAnkiIntegration: () => { + setIntegrationCalls += 1; + }, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => 'auto', + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + assert.equal(createdIntegrations, 0); + assert.equal(startedIntegrations, 0); + assert.equal(setIntegrationCalls, 0); +}); + +test('initializeOverlayRuntime starts Anki integration when ankiConnect.enabled is true', () => { + let createdIntegrations = 0; + let startedIntegrations = 0; + let setIntegrationCalls = 0; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => false, + updateVisibleOverlayVisibility: () => {}, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => null, + getResolvedConfig: () => ({ + ankiConnect: { enabled: true } as never, + }), + getSubtitleTimingTracker: () => ({}), + getMpvClient: () => ({ + send: () => {}, + }), + getRuntimeOptionsManager: () => ({ + getEffectiveAnkiConnectConfig: (config) => config as never, + }), + createAnkiIntegration: (args) => { + createdIntegrations += 1; + assert.equal(args.config.enabled, true); + return { + start: () => { + startedIntegrations += 1; + }, + }; + }, + setAnkiIntegration: () => { + setIntegrationCalls += 1; + }, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => 'manual', + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + assert.equal(createdIntegrations, 1); + assert.equal(startedIntegrations, 1); + assert.equal(setIntegrationCalls, 1); +}); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index a055266..117b8b3 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -1,5 +1,4 @@ import { BrowserWindow } from 'electron'; -import { AnkiIntegration } from '../../anki-integration'; import { BaseWindowTracker, createWindowTracker } from '../../window-trackers'; import { AnkiConnectConfig, @@ -8,6 +7,40 @@ import { WindowGeometry, } from '../../types'; +type AnkiIntegrationLike = { + start: () => void; +}; + +type CreateAnkiIntegrationArgs = { + config: AnkiConnectConfig; + subtitleTimingTracker: unknown; + mpvClient: { send?: (payload: { command: string[] }) => void }; + showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; + createFieldGroupingCallback: () => ( + data: KikuFieldGroupingRequestData, + ) => Promise; + knownWordCacheStatePath: string; +}; + +function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiIntegrationLike { + const { AnkiIntegration } = require('../../anki-integration') as typeof import('../../anki-integration'); + return new AnkiIntegration( + args.config, + args.subtitleTimingTracker as never, + args.mpvClient as never, + (text: string) => { + if (args.mpvClient && typeof args.mpvClient.send === 'function') { + args.mpvClient.send({ + command: ['show-text', text, '3000'], + }); + } + }, + args.showDesktopNotification, + args.createFieldGroupingCallback(), + args.knownWordCacheStatePath, + ); +} + export function initializeOverlayRuntime(options: { backendOverride: string | null; createMainWindow: () => void; @@ -18,6 +51,10 @@ export function initializeOverlayRuntime(options: { getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; + createWindowTracker?: ( + override?: string | null, + targetMpvSocketPath?: string | null, + ) => BaseWindowTracker | null; getMpvSocketPath: () => string; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getSubtitleTimingTracker: () => unknown | null; @@ -33,11 +70,13 @@ export function initializeOverlayRuntime(options: { data: KikuFieldGroupingRequestData, ) => Promise; getKnownWordCacheStatePath: () => string; + createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike; }): void { options.createMainWindow(); options.registerGlobalShortcuts(); - const windowTracker = createWindowTracker(options.backendOverride, options.getMpvSocketPath()); + const createWindowTrackerHandler = options.createWindowTracker ?? createWindowTracker; + const windowTracker = createWindowTrackerHandler(options.backendOverride, options.getMpvSocketPath()); options.setWindowTracker(windowTracker); if (windowTracker) { windowTracker.onGeometryChange = (geometry: WindowGeometry) => { @@ -63,25 +102,24 @@ export function initializeOverlayRuntime(options: { const mpvClient = options.getMpvClient(); const runtimeOptionsManager = options.getRuntimeOptionsManager(); - if (config.ankiConnect && subtitleTimingTracker && mpvClient && runtimeOptionsManager) { + if ( + config.ankiConnect?.enabled === true && + subtitleTimingTracker && + mpvClient && + runtimeOptionsManager + ) { const effectiveAnkiConfig = runtimeOptionsManager.getEffectiveAnkiConnectConfig( config.ankiConnect, ); - const integration = new AnkiIntegration( - effectiveAnkiConfig, - subtitleTimingTracker as never, - mpvClient as never, - (text: string) => { - if (mpvClient && typeof mpvClient.send === 'function') { - mpvClient.send({ - command: ['show-text', text, '3000'], - }); - } - }, - options.showDesktopNotification, - options.createFieldGroupingCallback(), - options.getKnownWordCacheStatePath(), - ); + const createAnkiIntegration = options.createAnkiIntegration ?? createDefaultAnkiIntegration; + const integration = createAnkiIntegration({ + config: effectiveAnkiConfig, + subtitleTimingTracker, + mpvClient, + showDesktopNotification: options.showDesktopNotification, + createFieldGroupingCallback: options.createFieldGroupingCallback, + knownWordCacheStatePath: options.getKnownWordCacheStatePath(), + }); integration.start(); options.setAnkiIntegration(integration); }