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`.
- 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.
+5 -1
View File
@@ -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": [
+23 -1
View File
@@ -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
+5 -1
View File
@@ -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": [
+5 -1
View File
@@ -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',
},
{
+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', () => {
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);
}
});
+28 -3
View File
@@ -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';
}
@@ -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<number, () => void>();
+80 -44
View File
@@ -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<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 {
const hotReloadFields: string[] = [];
const restartRequiredFields: string[] = [];
const hotReloadFieldSet = new Set<string>();
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 };
}
+12
View File
@@ -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(
@@ -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';
+83 -5
View File
@@ -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<AnkiConnectConfig>) => 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<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) {
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) {
@@ -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<AnkiConnectConfig>) => 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<AnkiConnectConfig>) =>
deps.applyAnkiRuntimeConfigPatch(patch),
invalidateTokenizationCache: () => deps.invalidateTokenizationCache?.(),
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
});
}