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
This commit is contained in:
2026-05-18 23:59:08 -07:00
parent a299cf6b72
commit 222edaf4a0
13 changed files with 463 additions and 63 deletions
+2
View File
@@ -4,3 +4,5 @@ area: config
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer 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. - 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. - 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.
+5 -1
View File
@@ -46,6 +46,7 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -337,6 +338,7 @@
// ========================================== // ==========================================
// Auto Subtitle Sync // Auto Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual "defaultMode": "auto", // Subsync default mode. Values: auto | manual
@@ -470,7 +472,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // 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. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -550,6 +552,7 @@
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ========================================== // ==========================================
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -560,6 +563,7 @@
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": [ "primarySubLanguages": [
+23 -1
View File
@@ -99,7 +99,28 @@ Hot-reloadable fields:
- `keybindings` - `keybindings`
- `shortcuts` - `shortcuts`
- `secondarySub.defaultMode` - `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. 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. - Any other config sections still require restart.
- Shared top-level `ai` provider settings 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. - SubMiner shows an on-screen/system notification listing restart-required sections when they change.
### Configuration Options Overview ### Configuration Options Overview
+5 -1
View File
@@ -46,6 +46,7 @@
// Logging // Logging
// Controls logging verbosity. // Controls logging verbosity.
// Set to debug for full runtime diagnostics. // Set to debug for full runtime diagnostics.
// Hot-reload: logging.level applies live while SubMiner is running.
// ========================================== // ==========================================
"logging": { "logging": {
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
@@ -337,6 +338,7 @@
// ========================================== // ==========================================
// Auto Subtitle Sync // Auto Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual "defaultMode": "auto", // Subsync default mode. Values: auto | manual
@@ -470,7 +472,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // 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. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -550,6 +552,7 @@
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
// Hot-reload: Jimaku changes apply to the next Jimaku request.
// ========================================== // ==========================================
"jimaku": { "jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API. "apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
@@ -560,6 +563,7 @@
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
// ========================================== // ==========================================
"youtube": { "youtube": {
"primarySubLanguages": [ "primarySubLanguages": [
+5 -1
View File
@@ -33,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{ {
title: 'Logging', title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
key: 'logging', key: 'logging',
}, },
{ {
@@ -91,6 +92,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{ {
title: 'Auto Subtitle Sync', title: 'Auto Subtitle Sync',
description: ['Subsync engine and executable paths.'], description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync', key: 'subsync',
}, },
{ {
@@ -127,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ 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.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.', 'Most other AnkiConnect settings still require restart.',
], ],
@@ -136,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{ {
title: 'Jimaku', title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'], description: ['Jimaku API configuration and defaults.'],
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku', key: 'jimaku',
}, },
{ {
@@ -143,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: [ description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.', 'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
], ],
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
key: 'youtube', key: 'youtube',
}, },
{ {
+53
View File
@@ -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', () => { test('settings registry hides app-managed and inactive config surfaces', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath)); const paths = new Set(fields.map((candidate) => candidate.configPath));
for (const hiddenPath of [ 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.bindings',
'controller.preferredGamepadId', 'controller.preferredGamepadId',
'controller.preferredGamepadLabel', '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'); 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);
}
});
+28 -3
View File
@@ -57,6 +57,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.behavior.nPlusOneMatchMode', 'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField', 'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField', 'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings', 'controller.bindings',
'controller.preferredGamepadId', 'controller.preferredGamepadId',
'controller.preferredGamepadLabel', 'controller.preferredGamepadLabel',
@@ -75,7 +76,12 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.recentServers', 'jellyfin.recentServers',
] as const; ] 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([ const JSON_OBJECT_FIELDS = new Set([
'keybindings', 'keybindings',
@@ -610,9 +616,28 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') || pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') || pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' || 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.toggleKey' ||
path === 'stats.markWatchedKey' path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
) { ) {
return 'hot-reload'; return 'hot-reload';
} }
@@ -18,6 +18,78 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
assert.deepEqual(diff.restartRequiredFields, ['websocket']); 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', () => { test('config hot reload runtime debounces rapid watch events', () => {
let watchedChangeCallback: (() => void) | null = null; let watchedChangeCallback: (() => void) | null = null;
const pendingTimers = new Map<number, () => void>(); const pendingTimers = new Map<number, () => void>();
+80 -44
View File
@@ -29,27 +29,84 @@ function isEqual(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b); return JSON.stringify(a) === JSON.stringify(b);
} }
function isRecord(value: unknown): value is Record<string, unknown> {
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 { function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotReloadDiff {
const hotReloadFields: string[] = []; const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = []; const restartRequiredFields: string[] = [];
const hotReloadFieldSet = new Set<string>();
const changedPaths = collectChangedPaths(prev, next);
if (!isEqual(prev.subtitleStyle, next.subtitleStyle)) { for (const path of changedPaths) {
hotReloadFields.push('subtitleStyle'); const hotReloadField = hotReloadFieldForChangedPath(path);
} if (hotReloadField) {
if (!isEqual(prev.keybindings, next.keybindings)) { hotReloadFieldSet.add(hotReloadField);
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');
} }
const keys = new Set([ const keys = new Set([
@@ -67,37 +124,16 @@ function classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigHotRelo
continue; continue;
} }
if (key === 'secondarySub') { const changedPathsForKey = changedPaths.filter((path) => pathStartsWith(path, String(key)));
const normalizedPrev = { const hasRestartRequiredChange = changedPathsForKey.some(
...prev.secondarySub, (path) => !hotReloadFieldForChangedPath(path),
defaultMode: next.secondarySub.defaultMode, );
}; if (hasRestartRequiredChange) {
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])) {
restartRequiredFields.push(String(key)); restartRequiredFields.push(String(key));
} }
} }
hotReloadFields.push(...hotReloadFieldSet);
return { hotReloadFields, restartRequiredFields }; return { hotReloadFields, restartRequiredFields };
} }
+12
View File
@@ -1777,6 +1777,18 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
appState.ankiIntegration.applyRuntimeConfigPatch(patch); appState.ankiIntegration.applyRuntimeConfigPatch(patch);
} }
}, },
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
},
refreshSubtitlePrefetch: () => {
subtitlePrefetchService?.onSeek(lastObservedTimePos);
},
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
}, },
); );
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
@@ -11,7 +11,7 @@ import {
test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
const config = deepCloneConfig(DEFAULT_CONFIG); const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = []; const calls: string[] = [];
const ankiPatches: Array<{ enabled: boolean }> = []; const ankiPatches: unknown[] = [];
const sessionBindingWarnings: string[][] = []; const sessionBindingWarnings: string[][] = [];
const applyHotReload = createConfigHotReloadAppliedHandler({ const applyHotReload = createConfigHotReloadAppliedHandler({
@@ -25,7 +25,7 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`), calls.push(`broadcast:${channel}:${typeof payload === 'string' ? payload : 'object'}`),
applyAnkiRuntimeConfigPatch: (patch) => { 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.includes(`set:secondary:${config.secondarySub.defaultMode}`));
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
assert.ok(calls.includes('broadcast:config:hot-reload:object')); 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.equal(sessionBindingWarnings.length, 1);
assert.ok( assert.ok(
sessionBindingWarnings[0]?.some((message) => 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', () => { test('buildConfigHotReloadPayload includes independent primary subtitle mode', () => {
const config = deepCloneConfig(DEFAULT_CONFIG); const config = deepCloneConfig(DEFAULT_CONFIG);
config.subtitleStyle.primaryDefaultMode = 'hover'; config.subtitleStyle.primaryDefaultMode = 'hover';
+83 -5
View File
@@ -3,6 +3,7 @@ import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveKeybindings } from '../../core/utils/keybindings'; import { resolveKeybindings } from '../../core/utils/keybindings';
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config'; import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
type ConfigHotReloadAppliedDeps = { type ConfigHotReloadAppliedDeps = {
@@ -14,9 +15,11 @@ type ConfigHotReloadAppliedDeps = {
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
ai: ResolvedConfig['ankiConnect']['ai']['enabled']; invalidateTokenizationCache?: () => void;
}) => void; refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
}; };
type ConfigHotReloadMessageDeps = { 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<AnkiConnectConfig> | null {
const patch: Partial<AnkiConnectConfig> = {};
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<AnkiConnectConfig['fields']> = {};
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) { export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadAppliedDeps) {
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
const payload = buildConfigHotReloadPayload(config); const payload = buildConfigHotReloadPayload(config);
@@ -74,8 +141,19 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode); deps.broadcastToOverlayWindows('secondary-subtitle:mode', payload.secondarySubMode);
} }
if (diff.hotReloadFields.includes('ankiConnect.ai')) { const ankiPatch = buildAnkiRuntimeConfigPatch(diff, config);
deps.applyAnkiRuntimeConfigPatch({ ai: config.ankiConnect.ai.enabled }); 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) { if (diff.hotReloadFields.length > 0) {
@@ -3,6 +3,7 @@ import type {
ConfigHotReloadRuntimeDeps, ConfigHotReloadRuntimeDeps,
} from '../../core/services/config-hot-reload'; } from '../../core/services/config-hot-reload';
import type { ReloadConfigStrictResult } from '../../config'; import type { ReloadConfigStrictResult } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type { import type {
ConfigHotReloadPayload, ConfigHotReloadPayload,
ConfigValidationWarning, ConfigValidationWarning,
@@ -69,9 +70,11 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshGlobalAndOverlayShortcuts: () => void; refreshGlobalAndOverlayShortcuts: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void; setSecondarySubMode: (mode: SecondarySubMode) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
applyAnkiRuntimeConfigPatch: (patch: { applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) => void;
ai: ResolvedConfig['ankiConnect']['ai']['enabled']; invalidateTokenizationCache?: () => void;
}) => void; refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
}) { }) {
return () => ({ return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
@@ -84,8 +87,12 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
broadcastToOverlayWindows: (channel: string, payload: unknown) => broadcastToOverlayWindows: (channel: string, payload: unknown) =>
deps.broadcastToOverlayWindows(channel, payload), deps.broadcastToOverlayWindows(channel, payload),
applyAnkiRuntimeConfigPatch: (patch: { ai: ResolvedConfig['ankiConnect']['ai']['enabled'] }) => applyAnkiRuntimeConfigPatch: (patch: Partial<AnkiConnectConfig>) =>
deps.applyAnkiRuntimeConfigPatch(patch), deps.applyAnkiRuntimeConfigPatch(patch),
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
}); });
} }