From 222edaf4a0d74cc8fdb3a48a04d8d667a3ac5cc2 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 18 May 2026 23:59:08 -0700 Subject: [PATCH] feat: expand hot-reload to logging, jimaku, subsync, and Anki sub-fields - Mark logging.level, stats keys, jimaku.*, subsync.*, and granular ankiConnect fields (knownWords, nPlusOne, fields, isLapis, isKiku, behavior) as hot-reloadable - Refactor classifyConfigHotReloadDiff to path-walk diffing instead of per-key branches - Wire setLogLevel, invalidateTokenizationCache, refreshSubtitlePrefetch, refreshCurrentSubtitle into hot-reload applied handler - Exclude ai.* and ankiConnect.ai.* prefixes from config window; hide fields.translation - Update docs and config.example.jsonc hot-reload annotations --- changes/config-settings-window.md | 2 + config.example.jsonc | 6 +- docs-site/configuration.md | 24 +++- docs-site/public/config.example.jsonc | 6 +- src/config/definitions/template-sections.ts | 6 +- src/config/settings/registry.test.ts | 53 ++++++++ src/config/settings/registry.ts | 31 ++++- src/core/services/config-hot-reload.test.ts | 72 ++++++++++ src/core/services/config-hot-reload.ts | 124 +++++++++++------- src/main.ts | 12 ++ .../config-hot-reload-handlers.test.ts | 87 +++++++++++- .../runtime/config-hot-reload-handlers.ts | 88 ++++++++++++- .../runtime/config-hot-reload-main-deps.ts | 15 ++- 13 files changed, 463 insertions(+), 63 deletions(-) diff --git a/changes/config-settings-window.md b/changes/config-settings-window.md index 26d8d662..c0c4b051 100644 --- a/changes/config-settings-window.md +++ b/changes/config-settings-window.md @@ -4,3 +4,5 @@ area: config - Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`. - Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active. - Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling. +- Marked safe live config options in the Configuration window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options. +- Hid AI and translation fields from the Configuration window while keeping them supported in config files. diff --git a/config.example.jsonc b/config.example.jsonc index 01efc1ba..67ddc377 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -46,6 +46,7 @@ // Logging // Controls logging verbosity. // Set to debug for full runtime diagnostics. + // Hot-reload: logging.level applies live while SubMiner is running. // ========================================== "logging": { "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error @@ -337,6 +338,7 @@ // ========================================== // Auto Subtitle Sync // Subsync engine and executable paths. + // Hot-reload: subsync changes apply to the next subtitle sync run. // ========================================== "subsync": { "defaultMode": "auto", // Subsync default mode. Values: auto | manual @@ -470,7 +472,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -550,6 +552,7 @@ // ========================================== // Jimaku // Jimaku API configuration and defaults. + // Hot-reload: Jimaku changes apply to the next Jimaku request. // ========================================== "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. @@ -560,6 +563,7 @@ // ========================================== // YouTube Playback Settings // Defaults for managed subtitle language preferences and YouTube subtitle loading. + // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { "primarySubLanguages": [ diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 3f49d8f4..4b7e08d9 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -99,7 +99,28 @@ Hot-reloadable fields: - `keybindings` - `shortcuts` - `secondarySub.defaultMode` -- `ankiConnect.ai` +- `stats.toggleKey` +- `stats.markWatchedKey` +- `logging.level` +- `youtube.primarySubLanguages` +- `jimaku.*` +- `subsync.*` +- `ankiConnect.ai.enabled` +- `ankiConnect.behavior.autoUpdateNewCards` +- `ankiConnect.knownWords.highlightEnabled` +- `ankiConnect.knownWords.refreshMinutes` +- `ankiConnect.knownWords.addMinedWordsImmediately` +- `ankiConnect.knownWords.matchMode` +- `ankiConnect.knownWords.decks` +- `ankiConnect.nPlusOne.enabled` +- `ankiConnect.nPlusOne.minSentenceWords` +- `ankiConnect.fields.word` +- `ankiConnect.fields.audio` +- `ankiConnect.fields.image` +- `ankiConnect.fields.sentence` +- `ankiConnect.fields.miscInfo` +- `ankiConnect.isLapis.sentenceCardModel` +- `ankiConnect.isKiku.fieldGrouping` When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. @@ -107,6 +128,7 @@ Restart-required changes: - Any other config sections still require restart. - Shared top-level `ai` provider settings still require restart. +- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above. - SubMiner shows an on-screen/system notification listing restart-required sections when they change. ### Configuration Options Overview diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 01efc1ba..67ddc377 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -46,6 +46,7 @@ // Logging // Controls logging verbosity. // Set to debug for full runtime diagnostics. + // Hot-reload: logging.level applies live while SubMiner is running. // ========================================== "logging": { "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error @@ -337,6 +338,7 @@ // ========================================== // Auto Subtitle Sync // Subsync engine and executable paths. + // Hot-reload: subsync changes apply to the next subtitle sync run. // ========================================== "subsync": { "defaultMode": "auto", // Subsync default mode. Values: auto | manual @@ -470,7 +472,7 @@ // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. - // Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running. + // Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running. // Shared AI provider transport settings are read from top-level ai and typically require restart. // Most other AnkiConnect settings still require restart. // ========================================== @@ -550,6 +552,7 @@ // ========================================== // Jimaku // Jimaku API configuration and defaults. + // Hot-reload: Jimaku changes apply to the next Jimaku request. // ========================================== "jimaku": { "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. @@ -560,6 +563,7 @@ // ========================================== // YouTube Playback Settings // Defaults for managed subtitle language preferences and YouTube subtitle loading. + // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { "primarySubLanguages": [ diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index eaa7682b..7220ddf4 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -33,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Logging', description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], + notes: ['Hot-reload: logging.level applies live while SubMiner is running.'], key: 'logging', }, { @@ -91,6 +92,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Auto Subtitle Sync', description: ['Subsync engine and executable paths.'], + notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'], key: 'subsync', }, { @@ -127,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'AnkiConnect Integration', description: ['Automatic Anki updates and media generation options.'], notes: [ - 'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.', + 'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.', 'Most other AnkiConnect settings still require restart.', ], @@ -136,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Jimaku', description: ['Jimaku API configuration and defaults.'], + notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'], key: 'jimaku', }, { @@ -143,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ description: [ 'Defaults for managed subtitle language preferences and YouTube subtitle loading.', ], + notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'], key: 'youtube', }, { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 07ad2def..10a92b92 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -160,6 +160,17 @@ test('settings registry puts feature toggles first, then other toggles alphabeti test('settings registry hides app-managed and inactive config surfaces', () => { const paths = new Set(fields.map((candidate) => candidate.configPath)); for (const hiddenPath of [ + 'ai.enabled', + 'ai.apiKey', + 'ai.apiKeyCommand', + 'ai.model', + 'ai.baseUrl', + 'ai.systemPrompt', + 'ai.requestTimeoutMs', + 'ankiConnect.ai.enabled', + 'ankiConnect.ai.model', + 'ankiConnect.ai.systemPrompt', + 'ankiConnect.fields.translation', 'controller.bindings', 'controller.preferredGamepadId', 'controller.preferredGamepadLabel', @@ -176,3 +187,45 @@ test('settings registry hides app-managed and inactive config surfaces', () => { } assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary'); }); + +test('settings registry marks safe live config paths as hot-reloadable', () => { + for (const path of [ + 'stats.toggleKey', + 'stats.markWatchedKey', + 'logging.level', + 'youtube.primarySubLanguages', + 'jimaku.apiBaseUrl', + 'jimaku.languagePreference', + 'jimaku.maxEntryResults', + 'subsync.defaultMode', + 'ankiConnect.behavior.autoUpdateNewCards', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.addMinedWordsImmediately', + 'ankiConnect.knownWords.matchMode', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.enabled', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.fields.word', + 'ankiConnect.fields.audio', + 'ankiConnect.fields.image', + 'ankiConnect.fields.sentence', + 'ankiConnect.fields.miscInfo', + 'ankiConnect.isLapis.sentenceCardModel', + 'ankiConnect.isKiku.fieldGrouping', + ]) { + assert.equal(field(path).restartBehavior, 'hot-reload', path); + } +}); + +test('settings registry keeps unsafe config siblings restart-required', () => { + for (const path of [ + 'stats.serverPort', + 'ankiConnect.url', + 'ankiConnect.proxy.enabled', + 'mpv.socketPath', + 'websocket.port', + ]) { + assert.equal(field(path).restartBehavior, 'restart', path); + } +}); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 6f4206fd..956ee466 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -57,6 +57,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'ankiConnect.behavior.nPlusOneMatchMode', 'ankiConnect.isLapis.sentenceCardSentenceField', 'ankiConnect.isLapis.sentenceCardAudioField', + 'ankiConnect.fields.translation', 'controller.bindings', 'controller.preferredGamepadId', 'controller.preferredGamepadLabel', @@ -75,7 +76,12 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'jellyfin.recentServers', ] as const; -const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const; +const EXCLUDED_PREFIXES = [ + 'ai', + 'ankiConnect.ai', + 'controller.buttonIndices', + 'youtubeSubgen', +] as const; const JSON_OBJECT_FIELDS = new Set([ 'keybindings', @@ -610,9 +616,28 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { pathStartsWith(path, 'subtitleStyle') || pathStartsWith(path, 'subtitleSidebar') || path === 'secondarySub.defaultMode' || - pathStartsWith(path, 'ankiConnect.ai') || + path === 'ankiConnect.ai.enabled' || + path === 'ankiConnect.behavior.autoUpdateNewCards' || + path === 'ankiConnect.knownWords.highlightEnabled' || + path === 'ankiConnect.knownWords.refreshMinutes' || + path === 'ankiConnect.knownWords.addMinedWordsImmediately' || + path === 'ankiConnect.knownWords.matchMode' || + path === 'ankiConnect.knownWords.decks' || + path === 'ankiConnect.nPlusOne.enabled' || + path === 'ankiConnect.nPlusOne.minSentenceWords' || + path === 'ankiConnect.fields.word' || + path === 'ankiConnect.fields.audio' || + path === 'ankiConnect.fields.image' || + path === 'ankiConnect.fields.sentence' || + path === 'ankiConnect.fields.miscInfo' || + path === 'ankiConnect.isLapis.sentenceCardModel' || + path === 'ankiConnect.isKiku.fieldGrouping' || path === 'stats.toggleKey' || - path === 'stats.markWatchedKey' + path === 'stats.markWatchedKey' || + path === 'logging.level' || + path === 'youtube.primarySubLanguages' || + pathStartsWith(path, 'jimaku') || + pathStartsWith(path, 'subsync') ) { return 'hot-reload'; } diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts index bc88c42a..e62b2b47 100644 --- a/src/core/services/config-hot-reload.test.ts +++ b/src/core/services/config-hot-reload.test.ts @@ -18,6 +18,78 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', () assert.deepEqual(diff.restartRequiredFields, ['websocket']); }); +test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => { + const prev = deepCloneConfig(DEFAULT_CONFIG); + const next = deepCloneConfig(DEFAULT_CONFIG); + next.stats.toggleKey = 'F8'; + next.stats.markWatchedKey = 'F9'; + next.logging.level = 'debug'; + next.youtube.primarySubLanguages = ['ja', 'en']; + next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; + next.subsync.defaultMode = prev.subsync.defaultMode === 'auto' ? 'manual' : 'auto'; + next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards; + next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled; + next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5; + next.ankiConnect.knownWords.addMinedWordsImmediately = + !prev.ankiConnect.knownWords.addMinedWordsImmediately; + next.ankiConnect.knownWords.matchMode = + prev.ankiConnect.knownWords.matchMode === 'headword' ? 'surface' : 'headword'; + next.ankiConnect.knownWords.decks = { Anime: ['Mining'] }; + next.ankiConnect.nPlusOne.enabled = !prev.ankiConnect.nPlusOne.enabled; + next.ankiConnect.nPlusOne.minSentenceWords = prev.ankiConnect.nPlusOne.minSentenceWords + 1; + next.ankiConnect.fields.word = 'Vocabulary'; + next.ankiConnect.fields.audio = 'SentenceAudioCustom'; + next.ankiConnect.fields.image = 'ScreenshotCustom'; + next.ankiConnect.fields.sentence = 'SentenceCustom'; + next.ankiConnect.fields.miscInfo = 'MiscInfoCustom'; + next.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom'; + next.ankiConnect.isKiku.fieldGrouping = + prev.ankiConnect.isKiku.fieldGrouping === 'auto' ? 'manual' : 'auto'; + + const diff = classifyConfigHotReloadDiff(prev, next); + + assert.deepEqual( + new Set(diff.hotReloadFields), + new Set([ + 'stats.toggleKey', + 'stats.markWatchedKey', + 'logging.level', + 'youtube.primarySubLanguages', + 'jimaku.maxEntryResults', + 'subsync.defaultMode', + 'ankiConnect.behavior.autoUpdateNewCards', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.addMinedWordsImmediately', + 'ankiConnect.knownWords.matchMode', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.enabled', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.fields.word', + 'ankiConnect.fields.audio', + 'ankiConnect.fields.image', + 'ankiConnect.fields.sentence', + 'ankiConnect.fields.miscInfo', + 'ankiConnect.isLapis.sentenceCardModel', + 'ankiConnect.isKiku.fieldGrouping', + ]), + ); + assert.deepEqual(diff.restartRequiredFields, []); +}); + +test('classifyConfigHotReloadDiff keeps unsafe nested siblings restart-required', () => { + const prev = deepCloneConfig(DEFAULT_CONFIG); + const next = deepCloneConfig(DEFAULT_CONFIG); + next.stats.serverPort = prev.stats.serverPort + 1; + next.ankiConnect.url = 'http://127.0.0.1:9999'; + next.ankiConnect.ai.model = 'openrouter/new-model'; + + const diff = classifyConfigHotReloadDiff(prev, next); + + assert.deepEqual(diff.hotReloadFields, []); + assert.deepEqual(diff.restartRequiredFields, ['ankiConnect', 'stats']); +}); + test('config hot reload runtime debounces rapid watch events', () => { let watchedChangeCallback: (() => void) | null = null; const pendingTimers = new Map void>(); diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index fc4ba873..14a9a3f9 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -29,27 +29,84 @@ function isEqual(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function pathStartsWith(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`); +} + +function collectChangedPaths(prev: unknown, next: unknown, prefix = ''): string[] { + if (isEqual(prev, next)) { + return []; + } + + if (!isRecord(prev) || !isRecord(next)) { + return prefix ? [prefix] : []; + } + + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + return [...keys].flatMap((key) => + collectChangedPaths(prev[key], next[key], prefix ? `${prefix}.${key}` : key), + ); +} + +const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const; + +const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ + 'secondarySub.defaultMode', + 'ankiConnect.ai.enabled', + 'stats.toggleKey', + 'stats.markWatchedKey', + 'logging.level', + 'youtube.primarySubLanguages', + 'jimaku', + 'subsync', + 'ankiConnect.behavior.autoUpdateNewCards', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.addMinedWordsImmediately', + 'ankiConnect.knownWords.matchMode', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.enabled', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.fields.word', + 'ankiConnect.fields.audio', + 'ankiConnect.fields.image', + 'ankiConnect.fields.sentence', + 'ankiConnect.fields.miscInfo', + 'ankiConnect.isLapis.sentenceCardModel', + 'ankiConnect.isKiku.fieldGrouping', +] as const; + +function hotReloadFieldForChangedPath(path: string): string | null { + for (const root of HOT_RELOAD_ROOTS) { + if (pathStartsWith(path, root)) { + return root; + } + } + + for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) { + if (pathStartsWith(path, hotPath)) { + return hotPath === 'jimaku' || hotPath === 'subsync' ? path : hotPath; + } + } + + return null; +} + function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff { const hotReloadFields: string[] = []; const restartRequiredFields: string[] = []; + const hotReloadFieldSet = new Set(); + const changedPaths = collectChangedPaths(prev, next); - if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) { - hotReloadFields.push('subtitleStyle'); - } - if (!isEqual(prev.keybindings, next.keybindings)) { - hotReloadFields.push('keybindings'); - } - if (!isEqual(prev.shortcuts, next.shortcuts)) { - hotReloadFields.push('shortcuts'); - } - if (!isEqual(prev.subtitleSidebar, next.subtitleSidebar)) { - hotReloadFields.push('subtitleSidebar'); - } - if (prev.secondarySub.defaultMode !== next.secondarySub.defaultMode) { - hotReloadFields.push('secondarySub.defaultMode'); - } - if (!isEqual(prev.ankiConnect.ai, next.ankiConnect.ai)) { - hotReloadFields.push('ankiConnect.ai'); + for (const path of changedPaths) { + const hotReloadField = hotReloadFieldForChangedPath(path); + if (hotReloadField) { + hotReloadFieldSet.add(hotReloadField); + } } const keys = new Set([ @@ -67,37 +124,16 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo continue; } - if (key === 'secondarySub') { - const normalizedPrev = { - ...prev.secondarySub, - defaultMode: next.secondarySub.defaultMode, - }; - if (!isEqual(normalizedPrev, next.secondarySub)) { - restartRequiredFields.push('secondarySub'); - } - continue; - } - - if (key === 'ankiConnect') { - const normalizedPrev = { - ...prev.ankiConnect, - ai: { - enabled: next.ankiConnect.ai.enabled, - model: prev.ankiConnect.ai.model, - systemPrompt: prev.ankiConnect.ai.systemPrompt, - }, - }; - if (!isEqual(normalizedPrev, next.ankiConnect)) { - restartRequiredFields.push('ankiConnect'); - } - continue; - } - - if (!isEqual(prev[key], next[key])) { + const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key))); + const hasRestartRequiredChange = changedPathsForKey.some( + (path) => !hotReloadFieldForChangedPath(path), + ); + if (hasRestartRequiredChange) { restartRequiredFields.push(String(key)); } } + hotReloadFields.push(...hotReloadFieldSet); return { hotReloadFields, restartRequiredFields }; } diff --git a/src/main.ts b/src/main.ts index 34c242ca..b5e773c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1777,6 +1777,18 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp appState.ankiIntegration.applyRuntimeConfigPatch(patch); } }, + invalidateTokenizationCache: () => { + subtitleProcessingController.invalidateTokenizationCache(); + }, + refreshSubtitlePrefetch: () => { + subtitlePrefetchService?.onSeek(lastObservedTimePos); + }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, + setLogLevel: (level) => { + setLogLevel(level, 'config'); + }, }, ); const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 533ab8d2..33d1d2df 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -11,7 +11,7 @@ import { test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { const config = deepCloneConfig(DEFAULT_CONFIG); const calls: string[] = []; - const ankiPatches: Array<{ enabled: boolean }> = []; + const ankiPatches: unknown[] = []; const sessionBindingWarnings: string[][] = []; const applyHotReload = createConfigHotReloadAppliedHandler({ @@ -25,7 +25,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`), applyAnkiRuntimeConfigPatch: (patch) => { - ankiPatches.push({ enabled: patch.ai }); + ankiPatches.push(patch); }, }); @@ -48,7 +48,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.includes('broadcast:config:hot-reload:object')); - assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]); + assert.deepEqual(ankiPatches, [{ ai: config.ankiConnect.ai.enabled }]); assert.equal(sessionBindingWarnings.length, 1); assert.ok( sessionBindingWarnings[0]?.some((message) => @@ -57,6 +57,87 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { ); }); +test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and logging changes', () => { + const config = deepCloneConfig(DEFAULT_CONFIG); + config.ankiConnect.behavior.autoUpdateNewCards = false; + config.ankiConnect.knownWords.highlightEnabled = true; + config.ankiConnect.knownWords.refreshMinutes = 90; + config.ankiConnect.knownWords.decks = { Anime: ['Mining'] }; + config.ankiConnect.nPlusOne.enabled = true; + config.ankiConnect.nPlusOne.minSentenceWords = 4; + config.ankiConnect.fields.word = 'Expression'; + config.ankiConnect.fields.audio = 'SentenceAudioCustom'; + config.ankiConnect.fields.image = 'ScreenshotCustom'; + config.ankiConnect.fields.sentence = 'SentenceCustom'; + config.ankiConnect.fields.miscInfo = 'MiscInfoCustom'; + config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom'; + config.ankiConnect.isKiku.fieldGrouping = 'manual'; + config.logging.level = 'debug'; + const calls: string[] = []; + const ankiPatches: unknown[] = []; + + const applyHotReload = createConfigHotReloadAppliedHandler({ + setKeybindings: () => calls.push('set:keybindings'), + setSessionBindings: () => calls.push('set:session-bindings'), + refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), + setSecondarySubMode: () => calls.push('set:secondary'), + broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), + applyAnkiRuntimeConfigPatch: (patch) => { + calls.push('anki:patch'); + ankiPatches.push(patch); + }, + invalidateTokenizationCache: () => calls.push('invalidate:tokens'), + refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'), + refreshCurrentSubtitle: () => calls.push('refresh:subtitle'), + setLogLevel: (level) => calls.push(`log:${level}`), + }); + + applyHotReload( + { + hotReloadFields: [ + 'ankiConnect.behavior.autoUpdateNewCards', + 'ankiConnect.knownWords.highlightEnabled', + 'ankiConnect.knownWords.refreshMinutes', + 'ankiConnect.knownWords.decks', + 'ankiConnect.nPlusOne.enabled', + 'ankiConnect.nPlusOne.minSentenceWords', + 'ankiConnect.fields.word', + 'ankiConnect.fields.audio', + 'ankiConnect.fields.image', + 'ankiConnect.fields.sentence', + 'ankiConnect.fields.miscInfo', + 'ankiConnect.isLapis.sentenceCardModel', + 'ankiConnect.isKiku.fieldGrouping', + 'logging.level', + ], + restartRequiredFields: [], + }, + config, + ); + + assert.deepEqual(ankiPatches, [ + { + behavior: { autoUpdateNewCards: false }, + knownWords: config.ankiConnect.knownWords, + nPlusOne: config.ankiConnect.nPlusOne, + fields: { + word: 'Expression', + audio: 'SentenceAudioCustom', + image: 'ScreenshotCustom', + sentence: 'SentenceCustom', + miscInfo: 'MiscInfoCustom', + }, + isLapis: { sentenceCardModel: 'Sentence Card Custom' }, + isKiku: { fieldGrouping: 'manual' }, + }, + ]); + assert.ok(calls.includes('invalidate:tokens')); + assert.ok(calls.includes('refresh:prefetch')); + assert.ok(calls.includes('refresh:subtitle')); + assert.ok(calls.includes('log:debug')); + assert.ok(calls.includes('broadcast:config:hot-reload')); +}); + test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => { const config = deepCloneConfig(DEFAULT_CONFIG); config.subtitleStyle.primaryDefaultMode = 'hover'; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 565f7b71..91472a02 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -3,6 +3,7 @@ import { compileSessionBindings } from '../../core/services/session-bindings'; import { resolveKeybindings } from '../../core/utils/keybindings'; import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config'; +import type { AnkiConnectConfig } from '../../types/anki'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; type ConfigHotReloadAppliedDeps = { @@ -14,9 +15,11 @@ type ConfigHotReloadAppliedDeps = { refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; - applyAnkiRuntimeConfigPatch: (patch: { - ai: ResolvedConfig['ankiConnect']['ai']['enabled']; - }) => void; + applyAnkiRuntimeConfigPatch: (patch: Partial) => void; + invalidateTokenizationCache?: () => void; + refreshSubtitlePrefetch?: () => void; + refreshCurrentSubtitle?: () => void; + setLogLevel?: (level: ResolvedConfig['logging']['level']) => void; }; type ConfigHotReloadMessageDeps = { @@ -59,6 +62,70 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe }; } +function hasAnyHotReloadField(diff: ConfigHotReloadDiff, prefixes: string[]): boolean { + return diff.hotReloadFields.some((field) => + prefixes.some((prefix) => field === prefix || field.startsWith(`${prefix}.`)), + ); +} + +function buildAnkiRuntimeConfigPatch( + diff: ConfigHotReloadDiff, + config: ResolvedConfig, +): Partial | null { + const patch: Partial = {}; + + if (diff.hotReloadFields.includes('ankiConnect.ai')) { + patch.ai = config.ankiConnect.ai.enabled; + } + if (diff.hotReloadFields.includes('ankiConnect.ai.enabled')) { + patch.ai = config.ankiConnect.ai.enabled; + } + if (diff.hotReloadFields.includes('ankiConnect.behavior.autoUpdateNewCards')) { + patch.behavior = { autoUpdateNewCards: config.ankiConnect.behavior.autoUpdateNewCards }; + } + if (hasAnyHotReloadField(diff, ['ankiConnect.knownWords'])) { + patch.knownWords = config.ankiConnect.knownWords; + } + if (hasAnyHotReloadField(diff, ['ankiConnect.nPlusOne'])) { + patch.nPlusOne = config.ankiConnect.nPlusOne; + } + const fieldPatch: NonNullable = {}; + if (diff.hotReloadFields.includes('ankiConnect.fields.word')) { + fieldPatch.word = config.ankiConnect.fields.word; + } + if (diff.hotReloadFields.includes('ankiConnect.fields.audio')) { + fieldPatch.audio = config.ankiConnect.fields.audio; + } + if (diff.hotReloadFields.includes('ankiConnect.fields.image')) { + fieldPatch.image = config.ankiConnect.fields.image; + } + if (diff.hotReloadFields.includes('ankiConnect.fields.sentence')) { + fieldPatch.sentence = config.ankiConnect.fields.sentence; + } + if (diff.hotReloadFields.includes('ankiConnect.fields.miscInfo')) { + fieldPatch.miscInfo = config.ankiConnect.fields.miscInfo; + } + if (Object.keys(fieldPatch).length > 0) { + patch.fields = fieldPatch; + } + if (diff.hotReloadFields.includes('ankiConnect.isLapis.sentenceCardModel')) { + patch.isLapis = { sentenceCardModel: config.ankiConnect.isLapis.sentenceCardModel }; + } + if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) { + patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping }; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean { + return hasAnyHotReloadField(diff, [ + 'ankiConnect.knownWords', + 'ankiConnect.nPlusOne', + 'ankiConnect.fields.word', + ]); +} + export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) { return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { const payload = buildConfigHotReloadPayload(config); @@ -74,8 +141,19 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode); } - if (diff.hotReloadFields.includes('ankiConnect.ai')) { - deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled }); + const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config); + if (ankiPatch) { + deps.applyAnkiRuntimeConfigPatch(ankiPatch); + } + + if (hasAnnotationRuntimeHotReload(diff)) { + deps.invalidateTokenizationCache?.(); + deps.refreshSubtitlePrefetch?.(); + deps.refreshCurrentSubtitle?.(); + } + + if (diff.hotReloadFields.includes('logging.level')) { + deps.setLogLevel?.(config.logging.level); } if (diff.hotReloadFields.length > 0) { diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index dfff9da9..0a2cd719 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -3,6 +3,7 @@ import type { ConfigHotReloadRuntimeDeps, } from '../../core/services/config-hot-reload'; import type { ReloadConfigStrictResult } from '../../config'; +import type { AnkiConnectConfig } from '../../types/anki'; import type { ConfigHotReloadPayload, ConfigValidationWarning, @@ -69,9 +70,11 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; - applyAnkiRuntimeConfigPatch: (patch: { - ai: ResolvedConfig['ankiConnect']['ai']['enabled']; - }) => void; + applyAnkiRuntimeConfigPatch: (patch: Partial) => void; + invalidateTokenizationCache?: () => void; + refreshSubtitlePrefetch?: () => void; + refreshCurrentSubtitle?: () => void; + setLogLevel?: (level: ResolvedConfig['logging']['level']) => void; }) { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => @@ -84,8 +87,12 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), broadcastToOverlayWindows: (channel: string, payload: unknown) => deps.broadcastToOverlayWindows(channel, payload), - applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => + applyAnkiRuntimeConfigPatch: (patch: Partial) => deps.applyAnkiRuntimeConfigPatch(patch), + invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(), + refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(), + refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), + setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level), }); }