mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user