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
@@ -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 };
}