mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 18:22:42 -08:00
feat(config): hot-reload safe config updates and document behavior
This commit is contained in:
@@ -258,6 +258,55 @@ test('parses jsonc and warns/falls back on invalid value', () => {
|
||||
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
|
||||
});
|
||||
|
||||
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"logging": {
|
||||
"level": "warn"
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().logging.level, 'warn');
|
||||
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"logging":`,
|
||||
);
|
||||
|
||||
const result = service.reloadConfigStrict();
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) {
|
||||
throw new Error('Expected strict reload to fail on invalid JSONC.');
|
||||
}
|
||||
assert.equal(result.path, configPath);
|
||||
assert.equal(service.getConfig().logging.level, 'warn');
|
||||
});
|
||||
|
||||
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().logging.level, 'error');
|
||||
|
||||
fs.writeFileSync(configPath, '{"logging":');
|
||||
|
||||
const result = service.reloadConfigStrict();
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) {
|
||||
throw new Error('Expected strict reload to fail on invalid JSON.');
|
||||
}
|
||||
assert.equal(result.path, configPath);
|
||||
assert.equal(service.getConfig().logging.level, 'error');
|
||||
});
|
||||
|
||||
test('accepts valid logging.level', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -715,11 +715,16 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: AI translation settings update live while SubMiner is running.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
key: 'ankiConnect',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
|
||||
key: 'shortcuts',
|
||||
},
|
||||
{
|
||||
@@ -737,11 +742,15 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Extra keybindings that are merged with built-in defaults.',
|
||||
'Set command to null to disable a default keybinding.',
|
||||
],
|
||||
notes: [
|
||||
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
|
||||
],
|
||||
key: 'keybindings',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Appearance',
|
||||
description: ['Primary and secondary subtitle styling.'],
|
||||
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
|
||||
key: 'subtitleStyle',
|
||||
},
|
||||
{
|
||||
@@ -750,6 +759,7 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'Dual subtitle track options.',
|
||||
'Used by subminer YouTube subtitle generation as secondary language preferences.',
|
||||
],
|
||||
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||
key: 'secondarySub',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
|
||||
import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||
|
||||
@@ -9,6 +9,19 @@ interface LoadResult {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type ReloadConfigStrictResult =
|
||||
| {
|
||||
ok: true;
|
||||
config: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -91,6 +104,26 @@ export class ConfigService {
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||
const loadResult = this.loadRawConfigStrict();
|
||||
if (!loadResult.ok) {
|
||||
return loadResult;
|
||||
}
|
||||
|
||||
const { config, path: configPath } = loadResult;
|
||||
this.rawConfig = config;
|
||||
this.configPathInUse = configPath;
|
||||
const { resolved, warnings } = this.resolveConfig(config);
|
||||
this.resolvedConfig = resolved;
|
||||
this.warnings = warnings;
|
||||
return {
|
||||
ok: true,
|
||||
config: this.getConfig(),
|
||||
warnings: [...warnings],
|
||||
path: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
saveRawConfig(config: RawConfig): void {
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
@@ -112,6 +145,20 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
private loadRawConfig(): LoadResult {
|
||||
const strictResult = this.loadRawConfigStrict();
|
||||
if (strictResult.ok) {
|
||||
return strictResult;
|
||||
}
|
||||
return { config: {}, path: strictResult.path };
|
||||
}
|
||||
|
||||
private loadRawConfigStrict():
|
||||
| (LoadResult & { ok: true })
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
path: string;
|
||||
} {
|
||||
const configPath = fs.existsSync(this.configFileJsonc)
|
||||
? this.configFileJsonc
|
||||
: fs.existsSync(this.configFileJson)
|
||||
@@ -119,18 +166,29 @@ export class ConfigService {
|
||||
: this.configFileJsonc;
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { config: {}, path: configPath };
|
||||
return { ok: true, config: {}, path: configPath };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
const parsed = configPath.endsWith('.jsonc')
|
||||
? (() => {
|
||||
const errors: ParseError[] = [];
|
||||
const result = parseJsonc(data, errors);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
|
||||
}
|
||||
return result;
|
||||
})()
|
||||
: JSON.parse(data);
|
||||
return {
|
||||
ok: true,
|
||||
config: isObject(parsed) ? (parsed as Config) : {},
|
||||
path: configPath,
|
||||
};
|
||||
} catch {
|
||||
return { config: {}, path: configPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown parse error';
|
||||
return { ok: false, error: message, path: configPath };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user