mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(config): improve startup validation and config error reporting
This commit is contained in:
@@ -368,6 +368,29 @@ test('falls back for invalid logging.level and reports warning', () => {
|
||||
assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
|
||||
});
|
||||
|
||||
test('warns and ignores unknown top-level config keys', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"websocket": {
|
||||
"port": 7788
|
||||
},
|
||||
"unknownFeatureFlag": {
|
||||
"enabled": true
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.websocket.port, 7788);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'unknownFeatureFlag'));
|
||||
});
|
||||
|
||||
test('parses invisible overlay config and new global shortcuts', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -634,6 +657,147 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('warns when ankiConnect.openRouter is used and migrates to ai', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"openRouter": {
|
||||
"model": "openrouter/test-model"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal((config.ankiConnect.ai as Record<string, unknown>).model, 'openrouter/test-model');
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'ankiConnect.openRouter' && warning.message.includes('ankiConnect.ai'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back and warns when legacy ankiConnect migration values are invalid', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"audioField": 123,
|
||||
"generateAudio": "yes",
|
||||
"imageType": "gif",
|
||||
"imageQuality": -1,
|
||||
"mediaInsertMode": "middle",
|
||||
"notificationType": "toast"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.fields.audio, DEFAULT_CONFIG.ankiConnect.fields.audio);
|
||||
assert.equal(
|
||||
config.ankiConnect.media.generateAudio,
|
||||
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
|
||||
);
|
||||
assert.equal(config.ankiConnect.media.imageType, DEFAULT_CONFIG.ankiConnect.media.imageType);
|
||||
assert.equal(
|
||||
config.ankiConnect.media.imageQuality,
|
||||
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
|
||||
);
|
||||
assert.equal(
|
||||
config.ankiConnect.behavior.mediaInsertMode,
|
||||
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
|
||||
);
|
||||
assert.equal(
|
||||
config.ankiConnect.behavior.notificationType,
|
||||
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
|
||||
);
|
||||
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.audioField'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.generateAudio'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageType'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.imageQuality'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.mediaInsertMode'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.notificationType'));
|
||||
});
|
||||
|
||||
test('maps valid legacy ankiConnect values to equivalent modern config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"audioField": "AudioLegacy",
|
||||
"imageField": "ImageLegacy",
|
||||
"generateAudio": false,
|
||||
"imageType": "avif",
|
||||
"imageFormat": "webp",
|
||||
"imageQuality": 88,
|
||||
"mediaInsertMode": "prepend",
|
||||
"notificationType": "both",
|
||||
"autoUpdateNewCards": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.fields.audio, 'AudioLegacy');
|
||||
assert.equal(config.ankiConnect.fields.image, 'ImageLegacy');
|
||||
assert.equal(config.ankiConnect.media.generateAudio, false);
|
||||
assert.equal(config.ankiConnect.media.imageType, 'avif');
|
||||
assert.equal(config.ankiConnect.media.imageFormat, 'webp');
|
||||
assert.equal(config.ankiConnect.media.imageQuality, 88);
|
||||
assert.equal(config.ankiConnect.behavior.mediaInsertMode, 'prepend');
|
||||
assert.equal(config.ankiConnect.behavior.notificationType, 'both');
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, false);
|
||||
});
|
||||
|
||||
test('ignores deprecated isLapis sentence-card field overrides', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"isLapis": {
|
||||
"enabled": true,
|
||||
"sentenceCardModel": "Japanese sentences",
|
||||
"sentenceCardSentenceField": "CustomSentence",
|
||||
"sentenceCardAudioField": "CustomAudio"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
const lapisConfig = config.ankiConnect.isLapis as Record<string, unknown>;
|
||||
assert.equal(lapisConfig.sentenceCardSentenceField, undefined);
|
||||
assert.equal(lapisConfig.sentenceCardAudioField, undefined);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardSentenceField'),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'ankiConnect.isLapis.sentenceCardAudioField'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect n+1 deck list', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -212,6 +212,12 @@ export class ConfigService {
|
||||
};
|
||||
|
||||
const src = isObject(raw) ? raw : {};
|
||||
const knownTopLevelKeys = new Set(Object.keys(resolved));
|
||||
for (const key of Object.keys(src)) {
|
||||
if (!knownTopLevelKeys.has(key)) {
|
||||
warn(key, src[key], undefined, 'Unknown top-level config key; ignored.');
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const openBrowser = asBoolean(src.texthooker.openBrowser);
|
||||
@@ -849,14 +855,57 @@ export class ConfigService {
|
||||
if (isObject(src.ankiConnect)) {
|
||||
const ac = src.ankiConnect;
|
||||
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
|
||||
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
|
||||
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
|
||||
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
|
||||
const aiSource = isObject(ac.ai) ? ac.ai : isObject(ac.openRouter) ? ac.openRouter : {};
|
||||
const legacyKeys = new Set([
|
||||
'audioField',
|
||||
'imageField',
|
||||
'sentenceField',
|
||||
'miscInfoField',
|
||||
'miscInfoPattern',
|
||||
'generateAudio',
|
||||
'generateImage',
|
||||
'imageType',
|
||||
'imageFormat',
|
||||
'imageQuality',
|
||||
'imageMaxWidth',
|
||||
'imageMaxHeight',
|
||||
'animatedFps',
|
||||
'animatedMaxWidth',
|
||||
'animatedMaxHeight',
|
||||
'animatedCrf',
|
||||
'audioPadding',
|
||||
'fallbackDuration',
|
||||
'maxMediaDuration',
|
||||
'overwriteAudio',
|
||||
'overwriteImage',
|
||||
'mediaInsertMode',
|
||||
'highlightWord',
|
||||
'notificationType',
|
||||
'autoUpdateNewCards',
|
||||
]);
|
||||
|
||||
if (ac.openRouter !== undefined) {
|
||||
warn(
|
||||
'ankiConnect.openRouter',
|
||||
ac.openRouter,
|
||||
resolved.ankiConnect.ai,
|
||||
'Deprecated key; use ankiConnect.ai instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
|
||||
ac as Record<string, unknown>;
|
||||
const ankiConnectWithoutLegacy = Object.fromEntries(
|
||||
Object.entries(ankiConnectWithoutNPlusOne).filter(([key]) => !legacyKeys.has(key)),
|
||||
);
|
||||
|
||||
resolved.ankiConnect = {
|
||||
...resolved.ankiConnect,
|
||||
...(isObject(ankiConnectWithoutNPlusOne)
|
||||
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig['ankiConnect']>)
|
||||
...(isObject(ankiConnectWithoutLegacy)
|
||||
? (ankiConnectWithoutLegacy as Partial<ResolvedConfig['ankiConnect']>)
|
||||
: {}),
|
||||
fields: {
|
||||
...resolved.ankiConnect.fields,
|
||||
@@ -884,7 +933,6 @@ export class ConfigService {
|
||||
},
|
||||
isLapis: {
|
||||
...resolved.ankiConnect.isLapis,
|
||||
...(isObject(ac.isLapis) ? (ac.isLapis as ResolvedConfig['ankiConnect']['isLapis']) : {}),
|
||||
},
|
||||
isKiku: {
|
||||
...resolved.ankiConnect.isKiku,
|
||||
@@ -892,6 +940,52 @@ export class ConfigService {
|
||||
},
|
||||
};
|
||||
|
||||
if (isObject(ac.isLapis)) {
|
||||
const lapisEnabled = asBoolean(ac.isLapis.enabled);
|
||||
if (lapisEnabled !== undefined) {
|
||||
resolved.ankiConnect.isLapis.enabled = lapisEnabled;
|
||||
} else if (ac.isLapis.enabled !== undefined) {
|
||||
warn(
|
||||
'ankiConnect.isLapis.enabled',
|
||||
ac.isLapis.enabled,
|
||||
resolved.ankiConnect.isLapis.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const sentenceCardModel = asString(ac.isLapis.sentenceCardModel);
|
||||
if (sentenceCardModel !== undefined) {
|
||||
resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
|
||||
} else if (ac.isLapis.sentenceCardModel !== undefined) {
|
||||
warn(
|
||||
'ankiConnect.isLapis.sentenceCardModel',
|
||||
ac.isLapis.sentenceCardModel,
|
||||
resolved.ankiConnect.isLapis.sentenceCardModel,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (ac.isLapis.sentenceCardSentenceField !== undefined) {
|
||||
warn(
|
||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||
ac.isLapis.sentenceCardSentenceField,
|
||||
'Sentence',
|
||||
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
|
||||
);
|
||||
}
|
||||
|
||||
if (ac.isLapis.sentenceCardAudioField !== undefined) {
|
||||
warn(
|
||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||
ac.isLapis.sentenceCardAudioField,
|
||||
'SentenceAudio',
|
||||
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
|
||||
);
|
||||
}
|
||||
} else if (ac.isLapis !== undefined) {
|
||||
warn('ankiConnect.isLapis', ac.isLapis, resolved.ankiConnect.isLapis, 'Expected object.');
|
||||
}
|
||||
|
||||
if (Array.isArray(ac.tags)) {
|
||||
const normalizedTags = ac.tags
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
@@ -919,89 +1013,344 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const legacy = ac as Record<string, unknown>;
|
||||
const mapLegacy = (key: string, apply: (value: unknown) => void): void => {
|
||||
if (legacy[key] !== undefined) apply(legacy[key]);
|
||||
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(obj, key);
|
||||
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const asPositiveInteger = (value: unknown): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const asPositiveNumber = (value: unknown): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const asNonNegativeNumber = (value: unknown): number | undefined => {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed === undefined || parsed < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
|
||||
return value === 'static' || value === 'avif' ? value : undefined;
|
||||
};
|
||||
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
|
||||
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
|
||||
};
|
||||
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
|
||||
return value === 'append' || value === 'prepend' ? value : undefined;
|
||||
};
|
||||
const asNotificationType = (
|
||||
value: unknown,
|
||||
): 'osd' | 'system' | 'both' | 'none' | undefined => {
|
||||
return value === 'osd' || value === 'system' || value === 'both' || value === 'none'
|
||||
? value
|
||||
: undefined;
|
||||
};
|
||||
const mapLegacy = <T>(
|
||||
key: string,
|
||||
parse: (value: unknown) => T | undefined,
|
||||
apply: (value: T) => void,
|
||||
fallback: unknown,
|
||||
message: string,
|
||||
): void => {
|
||||
const value = legacy[key];
|
||||
if (value === undefined) return;
|
||||
const parsed = parse(value);
|
||||
if (parsed === undefined) {
|
||||
warn(`ankiConnect.${key}`, value, fallback, message);
|
||||
return;
|
||||
}
|
||||
apply(parsed);
|
||||
};
|
||||
|
||||
mapLegacy('audioField', (value) => {
|
||||
resolved.ankiConnect.fields.audio = value as string;
|
||||
});
|
||||
mapLegacy('imageField', (value) => {
|
||||
resolved.ankiConnect.fields.image = value as string;
|
||||
});
|
||||
mapLegacy('sentenceField', (value) => {
|
||||
resolved.ankiConnect.fields.sentence = value as string;
|
||||
});
|
||||
mapLegacy('miscInfoField', (value) => {
|
||||
resolved.ankiConnect.fields.miscInfo = value as string;
|
||||
});
|
||||
mapLegacy('miscInfoPattern', (value) => {
|
||||
resolved.ankiConnect.metadata.pattern = value as string;
|
||||
});
|
||||
mapLegacy('generateAudio', (value) => {
|
||||
resolved.ankiConnect.media.generateAudio = value as boolean;
|
||||
});
|
||||
mapLegacy('generateImage', (value) => {
|
||||
resolved.ankiConnect.media.generateImage = value as boolean;
|
||||
});
|
||||
mapLegacy('imageType', (value) => {
|
||||
resolved.ankiConnect.media.imageType = value as 'static' | 'avif';
|
||||
});
|
||||
mapLegacy('imageFormat', (value) => {
|
||||
resolved.ankiConnect.media.imageFormat = value as 'jpg' | 'png' | 'webp';
|
||||
});
|
||||
mapLegacy('imageQuality', (value) => {
|
||||
resolved.ankiConnect.media.imageQuality = value as number;
|
||||
});
|
||||
mapLegacy('imageMaxWidth', (value) => {
|
||||
resolved.ankiConnect.media.imageMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy('imageMaxHeight', (value) => {
|
||||
resolved.ankiConnect.media.imageMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy('animatedFps', (value) => {
|
||||
resolved.ankiConnect.media.animatedFps = value as number;
|
||||
});
|
||||
mapLegacy('animatedMaxWidth', (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxWidth = value as number;
|
||||
});
|
||||
mapLegacy('animatedMaxHeight', (value) => {
|
||||
resolved.ankiConnect.media.animatedMaxHeight = value as number;
|
||||
});
|
||||
mapLegacy('animatedCrf', (value) => {
|
||||
resolved.ankiConnect.media.animatedCrf = value as number;
|
||||
});
|
||||
mapLegacy('audioPadding', (value) => {
|
||||
resolved.ankiConnect.media.audioPadding = value as number;
|
||||
});
|
||||
mapLegacy('fallbackDuration', (value) => {
|
||||
resolved.ankiConnect.media.fallbackDuration = value as number;
|
||||
});
|
||||
mapLegacy('maxMediaDuration', (value) => {
|
||||
resolved.ankiConnect.media.maxMediaDuration = value as number;
|
||||
});
|
||||
mapLegacy('overwriteAudio', (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteAudio = value as boolean;
|
||||
});
|
||||
mapLegacy('overwriteImage', (value) => {
|
||||
resolved.ankiConnect.behavior.overwriteImage = value as boolean;
|
||||
});
|
||||
mapLegacy('mediaInsertMode', (value) => {
|
||||
resolved.ankiConnect.behavior.mediaInsertMode = value as 'append' | 'prepend';
|
||||
});
|
||||
mapLegacy('highlightWord', (value) => {
|
||||
resolved.ankiConnect.behavior.highlightWord = value as boolean;
|
||||
});
|
||||
mapLegacy('notificationType', (value) => {
|
||||
resolved.ankiConnect.behavior.notificationType = value as
|
||||
| 'osd'
|
||||
| 'system'
|
||||
| 'both'
|
||||
| 'none';
|
||||
});
|
||||
mapLegacy('autoUpdateNewCards', (value) => {
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
|
||||
});
|
||||
if (!hasOwn(fields, 'audio')) {
|
||||
mapLegacy(
|
||||
'audioField',
|
||||
asString,
|
||||
(value) => {
|
||||
resolved.ankiConnect.fields.audio = value;
|
||||
},
|
||||
resolved.ankiConnect.fields.audio,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(fields, 'image')) {
|
||||
mapLegacy(
|
||||
'imageField',
|
||||
asString,
|
||||
(value) => {
|
||||
resolved.ankiConnect.fields.image = value;
|
||||
},
|
||||
resolved.ankiConnect.fields.image,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(fields, 'sentence')) {
|
||||
mapLegacy(
|
||||
'sentenceField',
|
||||
asString,
|
||||
(value) => {
|
||||
resolved.ankiConnect.fields.sentence = value;
|
||||
},
|
||||
resolved.ankiConnect.fields.sentence,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(fields, 'miscInfo')) {
|
||||
mapLegacy(
|
||||
'miscInfoField',
|
||||
asString,
|
||||
(value) => {
|
||||
resolved.ankiConnect.fields.miscInfo = value;
|
||||
},
|
||||
resolved.ankiConnect.fields.miscInfo,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(metadata, 'pattern')) {
|
||||
mapLegacy(
|
||||
'miscInfoPattern',
|
||||
asString,
|
||||
(value) => {
|
||||
resolved.ankiConnect.metadata.pattern = value;
|
||||
},
|
||||
resolved.ankiConnect.metadata.pattern,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'generateAudio')) {
|
||||
mapLegacy(
|
||||
'generateAudio',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.generateAudio = value;
|
||||
},
|
||||
resolved.ankiConnect.media.generateAudio,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'generateImage')) {
|
||||
mapLegacy(
|
||||
'generateImage',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.generateImage = value;
|
||||
},
|
||||
resolved.ankiConnect.media.generateImage,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'imageType')) {
|
||||
mapLegacy(
|
||||
'imageType',
|
||||
asImageType,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.imageType = value;
|
||||
},
|
||||
resolved.ankiConnect.media.imageType,
|
||||
"Expected 'static' or 'avif'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'imageFormat')) {
|
||||
mapLegacy(
|
||||
'imageFormat',
|
||||
asImageFormat,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.imageFormat = value;
|
||||
},
|
||||
resolved.ankiConnect.media.imageFormat,
|
||||
"Expected 'jpg', 'png', or 'webp'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'imageQuality')) {
|
||||
mapLegacy(
|
||||
'imageQuality',
|
||||
(value) => asIntegerInRange(value, 1, 100),
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.imageQuality = value;
|
||||
},
|
||||
resolved.ankiConnect.media.imageQuality,
|
||||
'Expected integer between 1 and 100.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'imageMaxWidth')) {
|
||||
mapLegacy(
|
||||
'imageMaxWidth',
|
||||
asPositiveInteger,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.imageMaxWidth = value;
|
||||
},
|
||||
resolved.ankiConnect.media.imageMaxWidth,
|
||||
'Expected positive integer.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'imageMaxHeight')) {
|
||||
mapLegacy(
|
||||
'imageMaxHeight',
|
||||
asPositiveInteger,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.imageMaxHeight = value;
|
||||
},
|
||||
resolved.ankiConnect.media.imageMaxHeight,
|
||||
'Expected positive integer.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'animatedFps')) {
|
||||
mapLegacy(
|
||||
'animatedFps',
|
||||
(value) => asIntegerInRange(value, 1, 60),
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.animatedFps = value;
|
||||
},
|
||||
resolved.ankiConnect.media.animatedFps,
|
||||
'Expected integer between 1 and 60.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'animatedMaxWidth')) {
|
||||
mapLegacy(
|
||||
'animatedMaxWidth',
|
||||
asPositiveInteger,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.animatedMaxWidth = value;
|
||||
},
|
||||
resolved.ankiConnect.media.animatedMaxWidth,
|
||||
'Expected positive integer.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'animatedMaxHeight')) {
|
||||
mapLegacy(
|
||||
'animatedMaxHeight',
|
||||
asPositiveInteger,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.animatedMaxHeight = value;
|
||||
},
|
||||
resolved.ankiConnect.media.animatedMaxHeight,
|
||||
'Expected positive integer.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'animatedCrf')) {
|
||||
mapLegacy(
|
||||
'animatedCrf',
|
||||
(value) => asIntegerInRange(value, 0, 63),
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.animatedCrf = value;
|
||||
},
|
||||
resolved.ankiConnect.media.animatedCrf,
|
||||
'Expected integer between 0 and 63.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'audioPadding')) {
|
||||
mapLegacy(
|
||||
'audioPadding',
|
||||
asNonNegativeNumber,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.audioPadding = value;
|
||||
},
|
||||
resolved.ankiConnect.media.audioPadding,
|
||||
'Expected non-negative number.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'fallbackDuration')) {
|
||||
mapLegacy(
|
||||
'fallbackDuration',
|
||||
asPositiveNumber,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.fallbackDuration = value;
|
||||
},
|
||||
resolved.ankiConnect.media.fallbackDuration,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(media, 'maxMediaDuration')) {
|
||||
mapLegacy(
|
||||
'maxMediaDuration',
|
||||
asNonNegativeNumber,
|
||||
(value) => {
|
||||
resolved.ankiConnect.media.maxMediaDuration = value;
|
||||
},
|
||||
resolved.ankiConnect.media.maxMediaDuration,
|
||||
'Expected non-negative number.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'overwriteAudio')) {
|
||||
mapLegacy(
|
||||
'overwriteAudio',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.overwriteAudio = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.overwriteAudio,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'overwriteImage')) {
|
||||
mapLegacy(
|
||||
'overwriteImage',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.overwriteImage = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.overwriteImage,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'mediaInsertMode')) {
|
||||
mapLegacy(
|
||||
'mediaInsertMode',
|
||||
asMediaInsertMode,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.mediaInsertMode = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.mediaInsertMode,
|
||||
"Expected 'append' or 'prepend'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'highlightWord')) {
|
||||
mapLegacy(
|
||||
'highlightWord',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.highlightWord = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.highlightWord,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'notificationType')) {
|
||||
mapLegacy(
|
||||
'notificationType',
|
||||
asNotificationType,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.notificationType = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.notificationType,
|
||||
"Expected 'osd', 'system', 'both', or 'none'.",
|
||||
);
|
||||
}
|
||||
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
|
||||
mapLegacy(
|
||||
'autoUpdateNewCards',
|
||||
asBoolean,
|
||||
(value) => {
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards = value;
|
||||
},
|
||||
resolved.ankiConnect.behavior.autoUpdateNewCards,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
|
||||
@@ -149,3 +149,92 @@ test('runAppReadyRuntime does not await background warmups', async () => {
|
||||
assert.ok(releaseWarmup);
|
||||
releaseWarmup();
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime exits before service init when critical anki mappings are invalid', async () => {
|
||||
const capturedErrors: string[][] = [];
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: 'auto' },
|
||||
secondarySub: {},
|
||||
ankiConnect: {
|
||||
enabled: true,
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
image: 'Picture',
|
||||
sentence: ' ',
|
||||
miscInfo: 'MiscInfo',
|
||||
translation: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
onCriticalConfigErrors: (errors) => {
|
||||
capturedErrors.push(errors);
|
||||
},
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(capturedErrors.length, 1);
|
||||
assert.deepEqual(capturedErrors[0], [
|
||||
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
|
||||
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
|
||||
]);
|
||||
assert.ok(calls.includes('reloadConfig'));
|
||||
assert.equal(calls.includes('createMpvClient'), false);
|
||||
assert.equal(calls.includes('initRuntimeOptionsManager'), false);
|
||||
assert.equal(calls.includes('startBackgroundWarmups'), false);
|
||||
});
|
||||
|
||||
test('runAppReadyRuntime aggregates multiple critical anki mapping errors', async () => {
|
||||
const capturedErrors: string[][] = [];
|
||||
const { deps, calls } = makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
websocket: { enabled: 'auto' },
|
||||
secondarySub: {},
|
||||
ankiConnect: {
|
||||
enabled: true,
|
||||
fields: {
|
||||
audio: ' ',
|
||||
image: '',
|
||||
sentence: '\t',
|
||||
miscInfo: ' ',
|
||||
translation: '',
|
||||
},
|
||||
},
|
||||
}),
|
||||
onCriticalConfigErrors: (errors) => {
|
||||
capturedErrors.push(errors);
|
||||
},
|
||||
});
|
||||
|
||||
await runAppReadyRuntime(deps);
|
||||
|
||||
assert.equal(capturedErrors.length, 1);
|
||||
assert.equal(capturedErrors[0].length, 5);
|
||||
assert.ok(
|
||||
capturedErrors[0].includes(
|
||||
'ankiConnect.fields.audio must be a non-empty string when ankiConnect is enabled.',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
capturedErrors[0].includes(
|
||||
'ankiConnect.fields.image must be a non-empty string when ankiConnect is enabled.',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
capturedErrors[0].includes(
|
||||
'ankiConnect.fields.sentence must be a non-empty string when ankiConnect is enabled.',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
capturedErrors[0].includes(
|
||||
'ankiConnect.fields.miscInfo must be a non-empty string when ankiConnect is enabled.',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
capturedErrors[0].includes(
|
||||
'ankiConnect.fields.translation must be a non-empty string when ankiConnect is enabled.',
|
||||
),
|
||||
);
|
||||
assert.equal(calls.includes('loadSubtitlePosition'), false);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,16 @@ interface AppReadyConfigLike {
|
||||
secondarySub?: {
|
||||
defaultMode?: SecondarySubMode;
|
||||
};
|
||||
ankiConnect?: {
|
||||
enabled?: boolean;
|
||||
fields?: {
|
||||
audio?: string;
|
||||
image?: string;
|
||||
sentence?: string;
|
||||
miscInfo?: string;
|
||||
translation?: string;
|
||||
};
|
||||
};
|
||||
websocket?: {
|
||||
enabled?: boolean | 'auto';
|
||||
port?: number;
|
||||
@@ -113,9 +123,38 @@ export interface AppReadyRuntimeDeps {
|
||||
initializeOverlayRuntime: () => void;
|
||||
handleInitialArgs: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
|
||||
'audio',
|
||||
'image',
|
||||
'sentence',
|
||||
'miscInfo',
|
||||
'translation',
|
||||
] as const;
|
||||
|
||||
function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
|
||||
if (!config.ankiConnect?.enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const fields = config.ankiConnect.fields ?? {};
|
||||
|
||||
for (const key of REQUIRED_ANKI_FIELD_MAPPING_KEYS) {
|
||||
const value = fields[key];
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
errors.push(
|
||||
`ankiConnect.fields.${key} must be a non-empty string when ankiConnect is enabled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function getInitialInvisibleOverlayVisibility(
|
||||
config: RuntimeConfigLike,
|
||||
platform: NodeJS.Platform,
|
||||
@@ -151,16 +190,25 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const startupStartedAtMs = now();
|
||||
deps.logDebug?.('App-ready critical path started.');
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
deps.createMpvClient();
|
||||
|
||||
deps.reloadConfig();
|
||||
const config = deps.getResolvedConfig();
|
||||
const criticalConfigErrors = getStartupCriticalConfigErrors(config);
|
||||
if (criticalConfigErrors.length > 0) {
|
||||
deps.onCriticalConfigErrors?.(criticalConfigErrors);
|
||||
deps.logDebug?.(
|
||||
`App-ready critical path aborted after config validation in ${now() - startupStartedAtMs}ms.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
|
||||
for (const warning of deps.getConfigWarnings()) {
|
||||
deps.logConfigWarning(warning);
|
||||
}
|
||||
|
||||
deps.loadSubtitlePosition();
|
||||
deps.resolveKeybindings();
|
||||
deps.createMpvClient();
|
||||
deps.initRuntimeOptionsManager();
|
||||
deps.setSecondarySubMode(config.secondarySub?.defaultMode ?? deps.defaultSecondarySubMode);
|
||||
|
||||
|
||||
65
src/main.ts
65
src/main.ts
@@ -26,6 +26,7 @@ import {
|
||||
Menu,
|
||||
Tray,
|
||||
nativeImage,
|
||||
dialog,
|
||||
} from 'electron';
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
@@ -61,6 +62,7 @@ import type {
|
||||
MpvSubtitleRenderMetrics,
|
||||
ResolvedConfig,
|
||||
ConfigHotReloadPayload,
|
||||
ConfigValidationWarning,
|
||||
} from './types';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
@@ -334,6 +336,40 @@ const appLogger = {
|
||||
},
|
||||
};
|
||||
|
||||
function formatConfigValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigWarningSummary(
|
||||
configPath: string,
|
||||
warnings: ConfigValidationWarning[],
|
||||
): string {
|
||||
const lines = [
|
||||
`[config] Validation found ${warnings.length} issue(s). File: ${configPath}`,
|
||||
...warnings.map(
|
||||
(warning, index) =>
|
||||
`[config] ${index + 1}. ${warning.path}: ${warning.message} actual=${formatConfigValue(warning.value)} fallback=${formatConfigValue(warning.fallback)}`,
|
||||
),
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function failStartupFromConfig(title: string, details: string): never {
|
||||
logger.error(details);
|
||||
dialog.showErrorBox(title, details);
|
||||
process.exitCode = 1;
|
||||
app.quit();
|
||||
throw new Error(details);
|
||||
}
|
||||
|
||||
function getDefaultSocketPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
@@ -2187,8 +2223,22 @@ const startupState = runStartupBootstrapRuntime(
|
||||
appState.mpvClient = createMpvClientRuntimeService();
|
||||
},
|
||||
reloadConfig: () => {
|
||||
configService.reloadConfig();
|
||||
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
|
||||
const result = configService.reloadConfigStrict();
|
||||
if (!result.ok) {
|
||||
failStartupFromConfig(
|
||||
'SubMiner config parse error',
|
||||
`Failed to parse config file at:\n${result.path}\n\nError: ${result.error}\n\nFix the config file and restart SubMiner.`,
|
||||
);
|
||||
}
|
||||
|
||||
appLogger.logInfo(`Using config file: ${result.path}`);
|
||||
if (result.warnings.length > 0) {
|
||||
appLogger.logWarning(buildConfigWarningSummary(result.path, result.warnings));
|
||||
showDesktopNotification('SubMiner', {
|
||||
body: `${result.warnings.length} config validation issue(s) detected. Defaults were applied where possible. File: ${result.path}`,
|
||||
});
|
||||
}
|
||||
|
||||
configHotReloadRuntime.start();
|
||||
void refreshAnilistClientSecretState({ force: true });
|
||||
},
|
||||
@@ -2285,6 +2335,17 @@ const startupState = runStartupBootstrapRuntime(
|
||||
appState.backgroundMode ? false : shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
handleInitialArgs: () => handleInitialArgs(),
|
||||
onCriticalConfigErrors: (errors: string[]) => {
|
||||
const configPath = configService.getConfigPath();
|
||||
const details = [
|
||||
`Critical config validation failed. File: ${configPath}`,
|
||||
'',
|
||||
...errors.map((error, index) => `${index + 1}. ${error}`),
|
||||
'',
|
||||
'Fix the config file and restart SubMiner.',
|
||||
].join('\n');
|
||||
failStartupFromConfig('SubMiner config validation error', details);
|
||||
},
|
||||
logDebug: (message: string) => {
|
||||
logger.debug(message);
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: AppReadyRuntimeDeps['shouldAutoInitializeOverlayRuntimeFromConfig'];
|
||||
initializeOverlayRuntime: AppReadyRuntimeDeps['initializeOverlayRuntime'];
|
||||
handleInitialArgs: AppReadyRuntimeDeps['handleInitialArgs'];
|
||||
onCriticalConfigErrors?: AppReadyRuntimeDeps['onCriticalConfigErrors'];
|
||||
logDebug?: AppReadyRuntimeDeps['logDebug'];
|
||||
now?: AppReadyRuntimeDeps['now'];
|
||||
}
|
||||
@@ -99,6 +100,7 @@ export function createAppReadyRuntimeDeps(
|
||||
params.shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
initializeOverlayRuntime: params.initializeOverlayRuntime,
|
||||
handleInitialArgs: params.handleInitialArgs,
|
||||
onCriticalConfigErrors: params.onCriticalConfigErrors,
|
||||
logDebug: params.logDebug,
|
||||
now: params.now,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user