mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-06 19:57:26 -08:00
refactor: extract anki integration runtime
This commit is contained in:
@@ -3,10 +3,10 @@ id: TASK-87.6
|
|||||||
title: >-
|
title: >-
|
||||||
Anki integration maintainability: continue decomposing the oversized
|
Anki integration maintainability: continue decomposing the oversized
|
||||||
orchestration layer
|
orchestration layer
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-06 03:20'
|
created_date: '2026-03-06 03:20'
|
||||||
updated_date: '2026-03-06 03:21'
|
updated_date: '2026-03-06 09:23'
|
||||||
labels:
|
labels:
|
||||||
- tech-debt
|
- tech-debt
|
||||||
- anki
|
- anki
|
||||||
@@ -40,10 +40,10 @@ src/anki-integration.ts remains an oversized orchestration file even after earli
|
|||||||
|
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
- [ ] #1 The responsibilities currently concentrated in src/anki-integration.ts are split into clearer modules or services with narrow ownership boundaries.
|
- [x] #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.
|
- [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.
|
||||||
- [ ] #3 Existing Anki integration behavior remains covered by automated verification, including note update, field grouping, and proxy-related flows that the refactor touches.
|
- [x] #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] #4 Any developer-facing docs or notes needed to understand the new structure are updated in the same task.
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|||||||
28
docs/anki-integration.md
Normal file
28
docs/anki-integration.md
Normal file
@@ -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.
|
||||||
@@ -222,9 +222,11 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
|
|||||||
);
|
);
|
||||||
|
|
||||||
const privateState = integration as unknown as {
|
const privateState = integration as unknown as {
|
||||||
|
runtime: {
|
||||||
proxyServer: unknown | null;
|
proxyServer: unknown | null;
|
||||||
};
|
};
|
||||||
assert.equal(privateState.proxyServer, null);
|
};
|
||||||
|
assert.equal(privateState.runtime.proxyServer, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { FieldGroupingService } from './anki-integration/field-grouping';
|
|||||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||||
import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
|
||||||
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
|
||||||
|
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration');
|
const log = createLogger('anki').child('integration');
|
||||||
|
|
||||||
@@ -113,8 +114,6 @@ export class AnkiIntegration {
|
|||||||
private timingTracker: SubtitleTimingTracker;
|
private timingTracker: SubtitleTimingTracker;
|
||||||
private config: AnkiConnectConfig;
|
private config: AnkiConnectConfig;
|
||||||
private pollingRunner!: PollingRunner;
|
private pollingRunner!: PollingRunner;
|
||||||
private proxyServer: AnkiConnectProxyServer | null = null;
|
|
||||||
private started = false;
|
|
||||||
private previousNoteIds = new Set<number>();
|
private previousNoteIds = new Set<number>();
|
||||||
private mpvClient: MpvClient;
|
private mpvClient: MpvClient;
|
||||||
private osdCallback: ((text: string) => void) | null = null;
|
private osdCallback: ((text: string) => void) | null = null;
|
||||||
@@ -135,6 +134,7 @@ export class AnkiIntegration {
|
|||||||
private fieldGroupingService: FieldGroupingService;
|
private fieldGroupingService: FieldGroupingService;
|
||||||
private noteUpdateWorkflow: NoteUpdateWorkflow;
|
private noteUpdateWorkflow: NoteUpdateWorkflow;
|
||||||
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
||||||
|
private runtime: AnkiIntegrationRuntime;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -148,7 +148,7 @@ export class AnkiIntegration {
|
|||||||
}) => Promise<KikuFieldGroupingChoice>,
|
}) => Promise<KikuFieldGroupingChoice>,
|
||||||
knownWordCacheStatePath?: string,
|
knownWordCacheStatePath?: string,
|
||||||
) {
|
) {
|
||||||
this.config = this.normalizeConfig(config);
|
this.config = normalizeAnkiIntegrationConfig(config);
|
||||||
this.client = new AnkiConnectClient(this.config.url!);
|
this.client = new AnkiConnectClient(this.config.url!);
|
||||||
this.mediaGenerator = new MediaGenerator();
|
this.mediaGenerator = new MediaGenerator();
|
||||||
this.timingTracker = timingTracker;
|
this.timingTracker = timingTracker;
|
||||||
@@ -163,6 +163,7 @@ export class AnkiIntegration {
|
|||||||
this.fieldGroupingService = this.createFieldGroupingService();
|
this.fieldGroupingService = this.createFieldGroupingService();
|
||||||
this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
|
this.noteUpdateWorkflow = this.createNoteUpdateWorkflow();
|
||||||
this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
|
this.fieldGroupingWorkflow = this.createFieldGroupingWorkflow();
|
||||||
|
this.runtime = this.createRuntime(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createFieldGroupingMergeCollaborator(): FieldGroupingMergeCollaborator {
|
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<AnkiConnectConfig['proxy']>)
|
|
||||||
: {};
|
|
||||||
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 {
|
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
|
||||||
return new KnownWordCacheManager({
|
return new KnownWordCacheManager({
|
||||||
client: {
|
client: {
|
||||||
@@ -302,11 +234,20 @@ export class AnkiIntegration {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateProxyServer(): AnkiConnectProxyServer {
|
private createRuntime(initialConfig: AnkiConnectConfig): AnkiIntegrationRuntime {
|
||||||
if (!this.proxyServer) {
|
return new AnkiIntegrationRuntime({
|
||||||
this.proxyServer = this.createProxyServer();
|
initialConfig,
|
||||||
}
|
pollingRunner: this.pollingRunner,
|
||||||
return this.proxyServer;
|
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 {
|
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 {
|
start(): void {
|
||||||
if (this.started) {
|
this.runtime.start();
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startKnownWordCacheLifecycle();
|
|
||||||
this.startTransport();
|
|
||||||
this.started = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopTransport();
|
this.runtime.stop();
|
||||||
this.stopKnownWordCacheLifecycle();
|
|
||||||
this.started = false;
|
|
||||||
log.info('Stopped AnkiConnect integration');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processNewCard(
|
private async processNewCard(
|
||||||
@@ -1216,58 +1105,7 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||||
const wasEnabled = this.config.nPlusOne?.highlightEnabled === true;
|
this.runtime.applyRuntimeConfigPatch(patch);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
|
|||||||
108
src/anki-integration/runtime.test.ts
Normal file
108
src/anki-integration/runtime.test.ts
Normal file
@@ -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<AnkiConnectConfig> = {},
|
||||||
|
overrides: Partial<ConstructorParameters<typeof AnkiIntegrationRuntime>[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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
233
src/anki-integration/runtime.ts
Normal file
233
src/anki-integration/runtime.ts
Normal file
@@ -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<AnkiConnectConfig['proxy']>)
|
||||||
|
: {};
|
||||||
|
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<AnkiConnectConfig>): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user