mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
247 lines
7.5 KiB
TypeScript
247 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
|
|
function normalizeAnkiAiConfig(
|
|
config: AnkiConnectConfig['ai'],
|
|
): NonNullable<AnkiConnectConfig['ai']> {
|
|
if (config && typeof config === 'object') {
|
|
return {
|
|
enabled: config.enabled === true,
|
|
model: trimToNonEmptyString(config.model) ?? '',
|
|
systemPrompt: trimToNonEmptyString(config.systemPrompt) ?? '',
|
|
};
|
|
}
|
|
|
|
return {
|
|
enabled: config === true,
|
|
model: '',
|
|
systemPrompt: '',
|
|
};
|
|
}
|
|
|
|
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: normalizeAnkiAiConfig(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();
|
|
}
|
|
}
|