From 85bd6c6ec2666a1b4e6f2f6d5ec5f8c796a95b85 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 6 Mar 2026 09:30:15 -0800 Subject: [PATCH] refactor: extract anki integration runtime --- ...osing-the-oversized-orchestration-layer.md | 12 +- docs/anki-integration.md | 28 +++ src/anki-integration.test.ts | 6 +- src/anki-integration.ts | 204 ++------------- src/anki-integration/runtime.test.ts | 108 ++++++++ src/anki-integration/runtime.ts | 233 ++++++++++++++++++ 6 files changed, 400 insertions(+), 191 deletions(-) create mode 100644 docs/anki-integration.md create mode 100644 src/anki-integration/runtime.test.ts create mode 100644 src/anki-integration/runtime.ts diff --git a/backlog/tasks/task-87.6 - Anki-integration-maintainability-continue-decomposing-the-oversized-orchestration-layer.md b/backlog/tasks/task-87.6 - Anki-integration-maintainability-continue-decomposing-the-oversized-orchestration-layer.md index 87cb397..7f3f89b 100644 --- a/backlog/tasks/task-87.6 - Anki-integration-maintainability-continue-decomposing-the-oversized-orchestration-layer.md +++ b/backlog/tasks/task-87.6 - Anki-integration-maintainability-continue-decomposing-the-oversized-orchestration-layer.md @@ -3,10 +3,10 @@ id: TASK-87.6 title: >- Anki integration maintainability: continue decomposing the oversized orchestration layer -status: To Do +status: Done assignee: [] created_date: '2026-03-06 03:20' -updated_date: '2026-03-06 03:21' +updated_date: '2026-03-06 09:23' labels: - tech-debt - anki @@ -40,10 +40,10 @@ src/anki-integration.ts remains an oversized orchestration file even after earli -- [ ] #1 The responsibilities currently concentrated in src/anki-integration.ts are split into clearer modules or services with narrow ownership boundaries. -- [ ] #2 The resulting orchestration surface is materially smaller and easier to review, with at least one mixed-responsibility cluster extracted behind a well-named interface. -- [ ] #3 Existing Anki integration behavior remains covered by automated verification, including note update, field grouping, and proxy-related flows that the refactor touches. -- [ ] #4 Any developer-facing docs or notes needed to understand the new structure are updated in the same task. +- [x] #1 The responsibilities currently concentrated in src/anki-integration.ts are split into clearer modules or services with narrow ownership boundaries. +- [x] #2 The resulting orchestration surface is materially smaller and easier to review, with at least one mixed-responsibility cluster extracted behind a well-named interface. +- [x] #3 Existing Anki integration behavior remains covered by automated verification, including note update, field grouping, and proxy-related flows that the refactor touches. +- [x] #4 Any developer-facing docs or notes needed to understand the new structure are updated in the same task. ## Implementation Plan diff --git a/docs/anki-integration.md b/docs/anki-integration.md new file mode 100644 index 0000000..1a334dc --- /dev/null +++ b/docs/anki-integration.md @@ -0,0 +1,28 @@ +# Anki Integration + +read_when: +- changing `src/anki-integration.ts` +- changing Anki transport/config hot-reload behavior +- tracing note update, field grouping, or proxy ownership + +## Ownership + +- `src/anki-integration.ts`: thin facade; wires dependencies; exposes public Anki API used by runtime/services. +- `src/anki-integration/runtime.ts`: normalized config state, polling-vs-proxy transport lifecycle, runtime config patch handling. +- `src/anki-integration/card-creation.ts`: sentence/audio card creation and clipboard update flow. +- `src/anki-integration/note-update-workflow.ts`: enrich newly added notes. +- `src/anki-integration/field-grouping.ts`: preview/build helpers for Kiku field grouping. +- `src/anki-integration/field-grouping-workflow.ts`: auto/manual merge execution. +- `src/anki-integration/anki-connect-proxy.ts`: local proxy transport for post-add enrichment. +- `src/anki-integration/known-word-cache.ts`: known-word cache lifecycle and persistence. + +## Refactor seam + +`AnkiIntegrationRuntime` owns the cluster that previously mixed: + +- config normalization/defaulting +- polling vs proxy startup/shutdown +- transport restart decisions during runtime patches +- known-word cache lifecycle toggles tied to config changes + +Keep new orchestration work in `runtime.ts` when it changes process-level Anki state. Keep note/card behavior in the workflow/service modules. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 5060ed7..e361182 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -222,9 +222,11 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis ); const privateState = integration as unknown as { - proxyServer: unknown | null; + runtime: { + proxyServer: unknown | null; + }; }; - assert.equal(privateState.proxyServer, null); + assert.equal(privateState.runtime.proxyServer, null); }); test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 2c3ae91..8943da8 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -48,6 +48,7 @@ import { FieldGroupingService } from './anki-integration/field-grouping'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; +import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime'; const log = createLogger('anki').child('integration'); @@ -113,8 +114,6 @@ 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; @@ -135,6 +134,7 @@ export class AnkiIntegration { private fieldGroupingService: FieldGroupingService; private noteUpdateWorkflow: NoteUpdateWorkflow; private fieldGroupingWorkflow: FieldGroupingWorkflow; + private runtime: AnkiIntegrationRuntime; constructor( config: AnkiConnectConfig, @@ -148,7 +148,7 @@ export class AnkiIntegration { }) => Promise, knownWordCacheStatePath?: string, ) { - this.config = this.normalizeConfig(config); + this.config = normalizeAnkiIntegrationConfig(config); this.client = new AnkiConnectClient(this.config.url!); this.mediaGenerator = new MediaGenerator(); this.timingTracker = timingTracker; @@ -163,6 +163,7 @@ export class AnkiIntegration { this.fieldGroupingService = this.createFieldGroupingService(); this.noteUpdateWorkflow = this.createNoteUpdateWorkflow(); this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow(); + this.runtime = this.createRuntime(config); } private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator { @@ -182,75 +183,6 @@ 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 ?? {}), - ...(config.ai ?? {}), - }, - media: { - ...DEFAULT_ANKI_CONNECT_CONFIG.media, - ...(config.media ?? {}), - }, - behavior: { - ...DEFAULT_ANKI_CONNECT_CONFIG.behavior, - ...(config.behavior ?? {}), - }, - metadata: { - ...DEFAULT_ANKI_CONNECT_CONFIG.metadata, - ...(config.metadata ?? {}), - }, - isLapis: { - ...DEFAULT_ANKI_CONNECT_CONFIG.isLapis, - ...(config.isLapis ?? {}), - }, - isKiku: { - ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, - ...(config.isKiku ?? {}), - }, - } as AnkiConnectConfig; - } - private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager { return new KnownWordCacheManager({ client: { @@ -302,11 +234,20 @@ export class AnkiIntegration { }); } - private getOrCreateProxyServer(): AnkiConnectProxyServer { - if (!this.proxyServer) { - this.proxyServer = this.createProxyServer(); - } - return this.proxyServer; + private createRuntime(initialConfig: AnkiConnectConfig): AnkiIntegrationRuntime { + return new AnkiIntegrationRuntime({ + initialConfig, + pollingRunner: this.pollingRunner, + knownWordCache: this.knownWordCache, + proxyServerFactory: () => this.createProxyServer(), + logInfo: (message, ...args) => log.info(message, ...args), + logWarn: (message, ...args) => log.warn(message, ...args), + logError: (message, ...args) => log.error(message, ...args), + onConfigChanged: (nextConfig) => { + this.config = nextConfig; + this.client = new AnkiConnectClient(nextConfig.url!); + }, + }); } private createCardCreationService(): CardCreationService { @@ -606,64 +547,12 @@ export class AnkiIntegration { }; } - 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.pollingRunner.start(); - } - - private stopTransport(): void { - this.pollingRunner.stop(); - this.proxyServer?.stop(); - } - start(): void { - if (this.started) { - this.stop(); - } - - this.startKnownWordCacheLifecycle(); - this.startTransport(); - this.started = true; + this.runtime.start(); } stop(): void { - this.stopTransport(); - this.stopKnownWordCacheLifecycle(); - this.started = false; - log.info('Stopped AnkiConnect integration'); + this.runtime.stop(); } private async processNewCard( @@ -1216,58 +1105,7 @@ export class AnkiIntegration { } applyRuntimeConfigPatch(patch: Partial): void { - const wasEnabled = this.config.nPlusOne?.highlightEnabled === true; - const previousTransportKey = this.getTransportConfigKey(this.config); - - const mergedConfig: AnkiConnectConfig = { - ...this.config, - ...patch, - nPlusOne: - patch.nPlusOne !== undefined - ? { - ...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne), - ...patch.nPlusOne, - } - : this.config.nPlusOne, - fields: - patch.fields !== undefined - ? { ...this.config.fields, ...patch.fields } - : this.config.fields, - media: - patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media, - behavior: - 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 } - : this.config.metadata, - isLapis: - patch.isLapis !== undefined - ? { ...this.config.isLapis, ...patch.isLapis } - : this.config.isLapis, - isKiku: - patch.isKiku !== undefined - ? { ...this.config.isKiku, ...patch.isKiku } - : this.config.isKiku, - }; - this.config = this.normalizeConfig(mergedConfig); - - if (wasEnabled && this.config.nPlusOne?.highlightEnabled === false) { - this.stopKnownWordCacheLifecycle(); - this.knownWordCache.clearKnownWordCacheState(); - } else { - this.startKnownWordCacheLifecycle(); - } - - const nextTransportKey = this.getTransportConfigKey(this.config); - if (this.started && previousTransportKey !== nextTransportKey) { - this.stopTransport(); - this.startTransport(); - } + this.runtime.applyRuntimeConfigPatch(patch); } destroy(): void { diff --git a/src/anki-integration/runtime.test.ts b/src/anki-integration/runtime.test.ts new file mode 100644 index 0000000..249d022 --- /dev/null +++ b/src/anki-integration/runtime.test.ts @@ -0,0 +1,108 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; +import type { AnkiConnectConfig } from '../types'; +import { AnkiIntegrationRuntime } from './runtime'; + +function createRuntime( + config: Partial = {}, + overrides: Partial[0]> = {}, +) { + const calls: string[] = []; + + const runtime = new AnkiIntegrationRuntime({ + initialConfig: config as AnkiConnectConfig, + pollingRunner: { + start: () => calls.push('polling:start'), + stop: () => calls.push('polling:stop'), + }, + knownWordCache: { + startLifecycle: () => calls.push('known:start'), + stopLifecycle: () => calls.push('known:stop'), + clearKnownWordCacheState: () => calls.push('known:clear'), + }, + proxyServerFactory: () => ({ + start: ({ host, port, upstreamUrl }) => + calls.push(`proxy:start:${host}:${port}:${upstreamUrl}`), + stop: () => calls.push('proxy:stop'), + }), + logInfo: () => undefined, + logWarn: () => undefined, + logError: () => undefined, + onConfigChanged: () => undefined, + ...overrides, + }); + + return { runtime, calls }; +} + +test('AnkiIntegrationRuntime normalizes url and proxy defaults', () => { + const { runtime } = createRuntime({ + url: ' http://anki.local:8765 ', + proxy: { + enabled: true, + host: ' 0.0.0.0 ', + port: 7001, + upstreamUrl: ' ', + }, + }); + + const normalized = runtime.getConfig(); + + assert.equal(normalized.url, 'http://anki.local:8765'); + assert.equal(normalized.proxy?.enabled, true); + assert.equal(normalized.proxy?.host, '0.0.0.0'); + assert.equal(normalized.proxy?.port, 7001); + assert.equal(normalized.proxy?.upstreamUrl, 'http://anki.local:8765'); + assert.equal(normalized.media?.fallbackDuration, DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration); +}); + +test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', () => { + const { runtime, calls } = createRuntime({ + proxy: { + enabled: true, + host: '127.0.0.1', + port: 9999, + upstreamUrl: 'http://upstream:8765', + }, + }); + + runtime.start(); + + assert.deepEqual(calls, [ + 'known:start', + 'proxy:start:127.0.0.1:9999:http://upstream:8765', + ]); +}); + +test('AnkiIntegrationRuntime switches transports and clears known words when runtime patch disables highlighting', () => { + const { runtime, calls } = createRuntime({ + nPlusOne: { + highlightEnabled: true, + }, + pollingRate: 250, + }); + + runtime.start(); + calls.length = 0; + + runtime.applyRuntimeConfigPatch({ + nPlusOne: { + highlightEnabled: false, + }, + proxy: { + enabled: true, + host: '127.0.0.1', + port: 8766, + upstreamUrl: 'http://127.0.0.1:8765', + }, + }); + + assert.deepEqual(calls, [ + 'known:stop', + 'known:clear', + 'polling:stop', + 'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765', + ]); +}); diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts new file mode 100644 index 0000000..1f25b4f --- /dev/null +++ b/src/anki-integration/runtime.ts @@ -0,0 +1,233 @@ +import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; +import type { AnkiConnectConfig } from '../types'; + +export interface AnkiIntegrationRuntimeProxyServer { + start(options: { host: string; port: number; upstreamUrl: string }): void; + stop(): void; +} + +interface AnkiIntegrationRuntimeDeps { + initialConfig: AnkiConnectConfig; + pollingRunner: { + start(): void; + stop(): void; + }; + knownWordCache: { + startLifecycle(): void; + stopLifecycle(): void; + clearKnownWordCacheState(): void; + }; + proxyServerFactory: () => AnkiIntegrationRuntimeProxyServer; + logInfo: (message: string, ...args: unknown[]) => void; + logWarn: (message: string, ...args: unknown[]) => void; + logError: (message: string, ...args: unknown[]) => void; + onConfigChanged?: (config: AnkiConnectConfig) => void; +} + +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiConnectConfig { + const resolvedUrl = + trimToNonEmptyString(config.url) ?? 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 = + trimToNonEmptyString(proxySource.host) ?? DEFAULT_ANKI_CONNECT_CONFIG.proxy?.host; + const normalizedProxyUpstreamUrl = trimToNonEmptyString(proxySource.upstreamUrl) ?? 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 ?? {}), + ...(config.ai ?? {}), + }, + media: { + ...DEFAULT_ANKI_CONNECT_CONFIG.media, + ...(config.media ?? {}), + }, + behavior: { + ...DEFAULT_ANKI_CONNECT_CONFIG.behavior, + ...(config.behavior ?? {}), + }, + metadata: { + ...DEFAULT_ANKI_CONNECT_CONFIG.metadata, + ...(config.metadata ?? {}), + }, + isLapis: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isLapis, + ...(config.isLapis ?? {}), + }, + isKiku: { + ...DEFAULT_ANKI_CONNECT_CONFIG.isKiku, + ...(config.isKiku ?? {}), + }, + } as AnkiConnectConfig; +} + +export class AnkiIntegrationRuntime { + private config: AnkiConnectConfig; + private proxyServer: AnkiIntegrationRuntimeProxyServer | null = null; + private started = false; + + constructor(private readonly deps: AnkiIntegrationRuntimeDeps) { + this.config = normalizeAnkiIntegrationConfig(deps.initialConfig); + } + + getConfig(): AnkiConnectConfig { + return this.config; + } + + start(): void { + if (this.started) { + this.stop(); + } + + this.deps.knownWordCache.startLifecycle(); + this.startTransport(); + this.started = true; + } + + stop(): void { + this.stopTransport(); + this.deps.knownWordCache.stopLifecycle(); + this.started = false; + this.deps.logInfo('Stopped AnkiConnect integration'); + } + + applyRuntimeConfigPatch(patch: Partial): void { + const wasKnownWordCacheEnabled = this.config.nPlusOne?.highlightEnabled === true; + const previousTransportKey = this.getTransportConfigKey(this.config); + + const mergedConfig: AnkiConnectConfig = { + ...this.config, + ...patch, + nPlusOne: + patch.nPlusOne !== undefined + ? { + ...(this.config.nPlusOne ?? DEFAULT_ANKI_CONNECT_CONFIG.nPlusOne), + ...patch.nPlusOne, + } + : this.config.nPlusOne, + fields: + patch.fields !== undefined + ? { ...this.config.fields, ...patch.fields } + : this.config.fields, + media: + patch.media !== undefined ? { ...this.config.media, ...patch.media } : this.config.media, + behavior: + 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 } + : this.config.metadata, + isLapis: + patch.isLapis !== undefined + ? { ...this.config.isLapis, ...patch.isLapis } + : this.config.isLapis, + isKiku: + patch.isKiku !== undefined + ? { ...this.config.isKiku, ...patch.isKiku } + : this.config.isKiku, + }; + this.config = normalizeAnkiIntegrationConfig(mergedConfig); + this.deps.onConfigChanged?.(this.config); + + if (wasKnownWordCacheEnabled && this.config.nPlusOne?.highlightEnabled === false) { + this.deps.knownWordCache.stopLifecycle(); + this.deps.knownWordCache.clearKnownWordCacheState(); + } else { + this.deps.knownWordCache.startLifecycle(); + } + + const nextTransportKey = this.getTransportConfigKey(this.config); + if (this.started && previousTransportKey !== nextTransportKey) { + this.stopTransport(); + this.startTransport(); + } + } + + getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer { + if (!this.proxyServer) { + this.proxyServer = this.deps.proxyServerFactory(); + } + return this.proxyServer; + } + + 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, + }); + this.deps.logInfo( + `Starting AnkiConnect integration with local proxy: http://${proxyHost}:${proxyPort} -> ${upstreamUrl}`, + ); + return; + } + + this.deps.logInfo( + 'Starting AnkiConnect integration with polling rate:', + this.config.pollingRate, + ); + this.deps.pollingRunner.start(); + } + + private stopTransport(): void { + this.deps.pollingRunner.stop(); + this.proxyServer?.stop(); + } +}