diff --git a/src/anki-integration/runtime.test.ts b/src/anki-integration/runtime.test.ts index 20bbbd2..ec2a271 100644 --- a/src/anki-integration/runtime.test.ts +++ b/src/anki-integration/runtime.test.ts @@ -110,3 +110,44 @@ test('AnkiIntegrationRuntime switches transports and clears known words when run 'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765', ]); }); + +test('AnkiIntegrationRuntime skips known-word lifecycle restart for unrelated runtime patches', () => { + const { runtime, calls } = createRuntime({ + knownWords: { + highlightEnabled: true, + }, + pollingRate: 250, + }); + + runtime.start(); + calls.length = 0; + + runtime.applyRuntimeConfigPatch({ + behavior: { + autoUpdateNewCards: false, + }, + }); + + assert.deepEqual(calls, []); +}); + +test('AnkiIntegrationRuntime restarts known-word lifecycle when known-word settings change', () => { + const { runtime, calls } = createRuntime({ + knownWords: { + highlightEnabled: true, + refreshMinutes: 90, + }, + pollingRate: 250, + }); + + runtime.start(); + calls.length = 0; + + runtime.applyRuntimeConfigPatch({ + knownWords: { + refreshMinutes: 120, + }, + }); + + assert.deepEqual(calls, ['known:start']); +}); diff --git a/src/anki-integration/runtime.ts b/src/anki-integration/runtime.ts index bccabfe..9be7f50 100644 --- a/src/anki-integration/runtime.ts +++ b/src/anki-integration/runtime.ts @@ -145,6 +145,9 @@ export class AnkiIntegrationRuntime { applyRuntimeConfigPatch(patch: Partial): void { const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true; + const previousKnownWordCacheConfig = wasKnownWordCacheEnabled + ? this.getKnownWordCacheLifecycleConfig(this.config) + : null; const previousTransportKey = this.getTransportConfigKey(this.config); const mergedConfig: AnkiConnectConfig = { @@ -191,11 +194,19 @@ export class AnkiIntegrationRuntime { }; this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.deps.onConfigChanged?.(this.config); + const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true; if (wasKnownWordCacheEnabled && this.config.knownWords?.highlightEnabled === false) { this.deps.knownWordCache.stopLifecycle(); this.deps.knownWordCache.clearKnownWordCacheState(); - } else { + } else if (!wasKnownWordCacheEnabled && nextKnownWordCacheEnabled) { + this.deps.knownWordCache.startLifecycle(); + } else if ( + wasKnownWordCacheEnabled && + nextKnownWordCacheEnabled && + previousKnownWordCacheConfig !== null && + previousKnownWordCacheConfig !== this.getKnownWordCacheLifecycleConfig(this.config) + ) { this.deps.knownWordCache.startLifecycle(); } @@ -206,6 +217,48 @@ export class AnkiIntegrationRuntime { } } + private getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string { + return JSON.stringify({ + refreshMinutes: this.getKnownWordRefreshIntervalMinutes(config), + scope: this.getKnownWordCacheScopeForConfig(config), + fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '', + }); + } + + private getKnownWordRefreshIntervalMinutes(config: AnkiConnectConfig): number { + const refreshMinutes = config.knownWords?.refreshMinutes; + return typeof refreshMinutes === 'number' && Number.isFinite(refreshMinutes) && refreshMinutes > 0 + ? refreshMinutes + : DEFAULT_ANKI_CONNECT_CONFIG.knownWords.refreshMinutes; + } + + private getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string { + const configuredDecks = config.knownWords?.decks; + if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { + const normalizedDecks = Object.entries(configuredDecks) + .map(([deckName, fields]) => { + const name = trimToNonEmptyString(deckName); + if (!name) return null; + const normalizedFields = Array.isArray(fields) + ? [ + ...new Set( + fields.map(String).map(trimToNonEmptyString).filter((field): field is string => Boolean(field)), + ), + ].sort() + : []; + return [name, normalizedFields]; + }) + .filter((entry): entry is [string, string[]] => entry !== null) + .sort(([a], [b]) => a.localeCompare(b)); + if (normalizedDecks.length > 0) { + return `decks:${JSON.stringify(normalizedDecks)}`; + } + } + + const configuredDeck = trimToNonEmptyString(config.deck); + return configuredDeck ? `deck:${configuredDeck}` : 'is:note'; + } + getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer { if (!this.proxyServer) { this.proxyServer = this.deps.proxyServerFactory();