feat: improve background startup and launcher control

Detach --background launches from terminals with quieter runtime output, make wrapper/plugin overlay start explicit, and allow trailing commas in JSONC configs for safer hot-reload edits. Includes pending Anki/docs/backlog updates in this unreleased batch.
This commit is contained in:
2026-02-18 02:22:01 -08:00
parent 4703b995da
commit ebaed49f76
34 changed files with 515 additions and 48 deletions

View File

@@ -17,6 +17,7 @@ test('loads defaults when config is missing', () => {
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.anilist.enabled, false);
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
@@ -258,6 +259,28 @@ test('parses jsonc and warns/falls back on invalid value', () => {
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
});
test('accepts trailing commas in jsonc', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"websocket": {
"enabled": "auto",
"port": 7788,
},
"youtubeSubgen": {
"primarySubLanguages": ["ja", "en",],
},
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, 7788);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']);
});
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
@@ -631,6 +654,44 @@ test('accepts valid ankiConnect n+1 deck list', () => {
assert.deepEqual(config.ankiConnect.nPlusOne.decks, ['Deck One', 'Deck Two']);
});
test('accepts valid ankiConnect tags list', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", "Mining"]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner', 'Mining']);
});
test('falls back to default when ankiConnect tags list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"tags": ["SubMiner", 123]
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.tags'));
});
test('falls back to default when ankiConnect n+1 deck list is invalid', () => {
const dir = makeTempDir();
fs.writeFileSync(

View File

@@ -79,6 +79,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: false,
url: 'http://127.0.0.1:8765',
pollingRate: 3000,
tags: ['SubMiner'],
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
@@ -397,6 +398,13 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate,
description: 'Polling interval in milliseconds.',
},
{
path: 'ankiConnect.tags',
kind: 'array',
defaultValue: DEFAULT_CONFIG.ankiConnect.tags,
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean',

View File

@@ -174,7 +174,10 @@ export class ConfigService {
const parsed = configPath.endsWith('.jsonc')
? (() => {
const errors: ParseError[] = [];
const result = parseJsonc(data, errors);
const result = parseJsonc(data, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
@@ -889,6 +892,32 @@ export class ConfigService {
},
};
if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ac.tags.length) {
resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
warn(
'ankiConnect.tags',
ac.tags,
resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ac.tags !== undefined) {
resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
warn(
'ankiConnect.tags',
ac.tags,
resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
const legacy = ac as Record<string, unknown>;
const mapLegacy = (key: string, apply: (value: unknown) => void): void => {
if (legacy[key] !== undefined) apply(legacy[key]);