diff --git a/changes/fix-anki-config-validation.md b/changes/fix-anki-config-validation.md new file mode 100644 index 00000000..9cbfa760 --- /dev/null +++ b/changes/fix-anki-config-validation.md @@ -0,0 +1,4 @@ +type: fixed +area: config + +- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 113bb314..d870695c 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -19,6 +19,8 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config. +AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`. + ## Auto-enrichment transport When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available: diff --git a/src/config/config.test.ts b/src/config/config.test.ts index e711bacd..45090419 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2818,6 +2818,29 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou ); }); +test('warns and falls back when isSenren.enabled is not boolean', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "ankiConnect": { + "isSenren": { "enabled": "true" } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + + assert.equal( + service.getConfig().ankiConnect.isSenren.enabled, + DEFAULT_CONFIG.ankiConnect.isSenren.enabled, + ); + assert.ok( + service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'), + ); +}); + test('accepts valid ankiConnect knownWords deck object', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 2611787e..2e0f76ee 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -32,6 +32,101 @@ test('media timing review is disabled by default and accepts a boolean override' assert.deepEqual(enabledContext.warnings, []); }); +test('invalid direct and field-grouping Anki values warn and keep defaults', () => { + const { context, warnings } = makeContext({ + enabled: 'true', + url: 8765, + pollingRate: '3000', + deck: ['Mining'], + isKiku: { + enabled: 1, + fieldGrouping: 'sometimes', + deleteDuplicateInAuto: 'false', + }, + isSenren: { + enabled: 'true', + fieldGrouping: false, + deleteDuplicateInAuto: 0, + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.enabled, DEFAULT_CONFIG.ankiConnect.enabled); + assert.equal(context.resolved.ankiConnect.url, DEFAULT_CONFIG.ankiConnect.url); + assert.equal(context.resolved.ankiConnect.pollingRate, DEFAULT_CONFIG.ankiConnect.pollingRate); + assert.equal(context.resolved.ankiConnect.deck, DEFAULT_CONFIG.ankiConnect.deck); + assert.deepEqual(context.resolved.ankiConnect.isKiku, DEFAULT_CONFIG.ankiConnect.isKiku); + assert.deepEqual(context.resolved.ankiConnect.isSenren, DEFAULT_CONFIG.ankiConnect.isSenren); + assert.deepEqual( + warnings.map((warning) => warning.path), + [ + 'ankiConnect.enabled', + 'ankiConnect.url', + 'ankiConnect.pollingRate', + 'ankiConnect.deck', + 'ankiConnect.isKiku.enabled', + 'ankiConnect.isKiku.deleteDuplicateInAuto', + 'ankiConnect.isKiku.fieldGrouping', + 'ankiConnect.isSenren.enabled', + 'ankiConnect.isSenren.deleteDuplicateInAuto', + 'ankiConnect.isSenren.fieldGrouping', + ], + ); +}); + +test('accepts valid direct and field-grouping Anki values', () => { + const { context, warnings } = makeContext({ + enabled: false, + url: 'http://127.0.0.1:9876', + pollingRate: 750, + deck: 'Mining', + isKiku: { + enabled: true, + fieldGrouping: 'manual', + deleteDuplicateInAuto: false, + }, + isSenren: { + enabled: false, + fieldGrouping: 'disabled', + deleteDuplicateInAuto: false, + }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(context.resolved.ankiConnect.enabled, false); + assert.equal(context.resolved.ankiConnect.url, 'http://127.0.0.1:9876'); + assert.equal(context.resolved.ankiConnect.pollingRate, 750); + assert.equal(context.resolved.ankiConnect.deck, 'Mining'); + assert.deepEqual(context.resolved.ankiConnect.isKiku, { + enabled: true, + fieldGrouping: 'manual', + deleteDuplicateInAuto: false, + }); + assert.deepEqual(context.resolved.ankiConnect.isSenren, { + enabled: false, + fieldGrouping: 'disabled', + deleteDuplicateInAuto: false, + }); + assert.deepEqual(warnings, []); +}); + +test('ignores unknown Anki keys without warning or admitting them to resolved config', () => { + const { context, warnings } = makeContext({ + futureOption: { enabled: true }, + isKiku: { futureGroupingOption: 'future' }, + isSenren: { futureGroupingOption: 'future' }, + }); + + applyAnkiConnectResolution(context); + + assert.equal(Object.hasOwn(context.resolved.ankiConnect, 'futureOption'), false); + assert.equal(Object.hasOwn(context.resolved.ankiConnect.isKiku, 'futureGroupingOption'), false); + assert.equal(Object.hasOwn(context.resolved.ankiConnect.isSenren, 'futureGroupingOption'), false); + assert.deepEqual(warnings, []); +}); + test('modern media duration accepts zero as the disabled cap sentinel', () => { const disabledCap = makeContext({ media: { maxMediaDuration: 0 } }); applyAnkiConnectResolution(disabledCap.context); diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 4e216344..f78791ab 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -19,11 +19,11 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { const media = isObject(ankiConnect.media) ? ankiConnect.media : {}; const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; - initializeAnkiConnectResolution(context, ankiConnect); + initializeAnkiConnectResolution(context); applyAnkiModernResolution(context, ankiConnect, behavior, media); applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata); applyAnkiKnownWordsResolution(context, ankiConnect, behavior); - applyAnkiKikuResolution(context); - applyAnkiSenrenResolution(context); + applyAnkiKikuResolution(context, ankiConnect); + applyAnkiSenrenResolution(context, ankiConnect); applyAnkiLapisKikuResolution(context, ankiConnect); } diff --git a/src/config/resolve/anki-connect/base.ts b/src/config/resolve/anki-connect/base.ts new file mode 100644 index 00000000..b26ebf98 --- /dev/null +++ b/src/config/resolve/anki-connect/base.ts @@ -0,0 +1,58 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean, asString } from '../shared'; +import { applyModernValue, asPositiveNumber } from './modern-value'; + +export function applyAnkiBaseResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyModernValue( + context, + ankiConnect, + 'enabled', + 'ankiConnect.enabled', + asBoolean, + DEFAULT_CONFIG.ankiConnect.enabled, + (value) => { + context.resolved.ankiConnect.enabled = value; + }, + 'Expected boolean.', + ); + applyModernValue( + context, + ankiConnect, + 'url', + 'ankiConnect.url', + asString, + DEFAULT_CONFIG.ankiConnect.url, + (value) => { + context.resolved.ankiConnect.url = value; + }, + 'Expected string.', + ); + applyModernValue( + context, + ankiConnect, + 'pollingRate', + 'ankiConnect.pollingRate', + asPositiveNumber, + DEFAULT_CONFIG.ankiConnect.pollingRate, + (value) => { + context.resolved.ankiConnect.pollingRate = value; + }, + 'Expected positive number.', + ); + applyModernValue( + context, + ankiConnect, + 'deck', + 'ankiConnect.deck', + asString, + DEFAULT_CONFIG.ankiConnect.deck, + (value) => { + context.resolved.ankiConnect.deck = value; + }, + 'Expected string.', + ); +} diff --git a/src/config/resolve/anki-connect/field-grouping-config.ts b/src/config/resolve/anki-connect/field-grouping-config.ts new file mode 100644 index 00000000..599be33a --- /dev/null +++ b/src/config/resolve/anki-connect/field-grouping-config.ts @@ -0,0 +1,53 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean, isObject } from '../shared'; +import { applyModernValue } from './modern-value'; + +type FieldGroupingConfigKey = 'isKiku' | 'isSenren'; + +export function applyFieldGroupingConfigResolution( + context: ResolveContext, + ankiConnect: Record, + key: FieldGroupingConfigKey, +): void { + const source = ankiConnect[key]; + if (!isObject(source)) { + if (source !== undefined) { + context.warn( + `ankiConnect.${key}`, + source, + DEFAULT_CONFIG.ankiConnect[key], + 'Expected object.', + ); + } + return; + } + + for (const booleanKey of ['enabled', 'deleteDuplicateInAuto'] as const) { + applyModernValue( + context, + source, + booleanKey, + `ankiConnect.${key}.${booleanKey}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect[key][booleanKey], + (value) => { + context.resolved.ankiConnect[key][booleanKey] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + source, + 'fieldGrouping', + `ankiConnect.${key}.fieldGrouping`, + (value) => (value === 'auto' || value === 'manual' || value === 'disabled' ? value : undefined), + DEFAULT_CONFIG.ankiConnect[key].fieldGrouping, + (value) => { + context.resolved.ankiConnect[key].fieldGrouping = value; + }, + 'Expected auto, manual, or disabled.', + ); +} diff --git a/src/config/resolve/anki-connect/initialize.ts b/src/config/resolve/anki-connect/initialize.ts index 2d1a8a2d..490d6888 100644 --- a/src/config/resolve/anki-connect/initialize.ts +++ b/src/config/resolve/anki-connect/initialize.ts @@ -1,55 +1,8 @@ import type { ResolveContext } from '../context'; -import { isObject } from '../shared'; - -const LEGACY_KEYS = new Set([ - 'wordField', - 'audioField', - 'imageField', - 'sentenceField', - 'miscInfoField', - 'miscInfoPattern', - 'generateAudio', - 'generateImage', - 'imageType', - 'imageFormat', - 'imageQuality', - 'imageMaxWidth', - 'imageMaxHeight', - 'animatedFps', - 'animatedMaxWidth', - 'animatedMaxHeight', - 'animatedCrf', - 'syncAnimatedImageToWordAudio', - 'audioPadding', - 'fallbackDuration', - 'maxMediaDuration', - 'overwriteAudio', - 'overwriteImage', - 'mediaInsertMode', - 'highlightWord', - 'notificationType', - 'autoUpdateNewCards', -]); - -export function initializeAnkiConnectResolution( - context: ResolveContext, - ankiConnect: Record, -): void { - const { - knownWords: _knownWordsConfigFromAnkiConnect, - nPlusOne: _nPlusOneConfigFromAnkiConnect, - ai: _ankiAiConfig, - ...ankiConnectWithoutKnownWordsOrNPlusOne - } = ankiConnect; - const ankiConnectWithoutLegacy = Object.fromEntries( - Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)), - ); +export function initializeAnkiConnectResolution(context: ResolveContext): void { context.resolved.ankiConnect = { ...context.resolved.ankiConnect, - ...(isObject(ankiConnectWithoutLegacy) - ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) - : {}), fields: { ...context.resolved.ankiConnect.fields, }, @@ -73,15 +26,9 @@ export function initializeAnkiConnectResolution( }, isKiku: { ...context.resolved.ankiConnect.isKiku, - ...(isObject(ankiConnect.isKiku) - ? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) - : {}), }, isSenren: { ...context.resolved.ankiConnect.isSenren, - ...(isObject(ankiConnect.isSenren) - ? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren']) - : {}), }, lapisKiku: { ...context.resolved.ankiConnect.lapisKiku, diff --git a/src/config/resolve/anki-connect/kiku.ts b/src/config/resolve/anki-connect/kiku.ts index bce4ddb5..c2a854df 100644 --- a/src/config/resolve/anki-connect/kiku.ts +++ b/src/config/resolve/anki-connect/kiku.ts @@ -1,19 +1,9 @@ -import { DEFAULT_CONFIG } from '../../definitions'; import type { ResolveContext } from '../context'; +import { applyFieldGroupingConfigResolution } from './field-grouping-config'; -export function applyAnkiKikuResolution(context: ResolveContext): void { - if ( - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' && - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' && - context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled' - ) { - context.warn( - 'ankiConnect.isKiku.fieldGrouping', - context.resolved.ankiConnect.isKiku.fieldGrouping, - DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping, - 'Expected auto, manual, or disabled.', - ); - context.resolved.ankiConnect.isKiku.fieldGrouping = - DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping; - } +export function applyAnkiKikuResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyFieldGroupingConfigResolution(context, ankiConnect, 'isKiku'); } diff --git a/src/config/resolve/anki-connect/modern.ts b/src/config/resolve/anki-connect/modern.ts index bb240cea..2ac882d8 100644 --- a/src/config/resolve/anki-connect/modern.ts +++ b/src/config/resolve/anki-connect/modern.ts @@ -1,6 +1,7 @@ import type { ResolveContext } from '../context'; import { isObject } from '../shared'; import { applyAiResolution } from './ai'; +import { applyAnkiBaseResolution } from './base'; import { applyLapisResolution } from './lapis'; import { applyModernBehaviorResolution } from './modern-behavior'; import { applyModernFieldsResolution } from './modern-fields'; @@ -18,6 +19,7 @@ export function applyAnkiModernResolution( const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {}; const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; + applyAnkiBaseResolution(context, ankiConnect); applyModernFieldsResolution(context, fields); applyModernMediaResolution(context, media); applyModernBehaviorResolution(context, behavior); diff --git a/src/config/resolve/anki-connect/senren.ts b/src/config/resolve/anki-connect/senren.ts index 950f1c58..e101f00d 100644 --- a/src/config/resolve/anki-connect/senren.ts +++ b/src/config/resolve/anki-connect/senren.ts @@ -1,21 +1,11 @@ -import { DEFAULT_CONFIG } from '../../definitions'; import type { ResolveContext } from '../context'; +import { applyFieldGroupingConfigResolution } from './field-grouping-config'; -export function applyAnkiSenrenResolution(context: ResolveContext): void { - if ( - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' && - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' && - context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled' - ) { - context.warn( - 'ankiConnect.isSenren.fieldGrouping', - context.resolved.ankiConnect.isSenren.fieldGrouping, - DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping, - 'Expected auto, manual, or disabled.', - ); - context.resolved.ankiConnect.isSenren.fieldGrouping = - DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping; - } +export function applyAnkiSenrenResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + applyFieldGroupingConfigResolution(context, ankiConnect, 'isSenren'); // Kiku and Senren field grouping write incompatible markup into the same note // fields, so only one may be active; Kiku wins to preserve pre-existing setups.