fix(config): validate AnkiConnect and field grouping settings (#257)

This commit is contained in:
2026-09-20 22:48:54 -07:00
committed by GitHub
parent 05425cc5db
commit 917d52dd94
11 changed files with 253 additions and 89 deletions
+4
View File
@@ -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.
+2
View File
@@ -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:
+23
View File
@@ -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(
+95
View File
@@ -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);
+3 -3
View File
@@ -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);
}
+58
View File
@@ -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<string, unknown>,
): 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.',
);
}
@@ -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<string, unknown>,
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.',
);
}
+1 -54
View File
@@ -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<string, unknown>,
): 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,
+6 -16
View File
@@ -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<string, unknown>,
): void {
applyFieldGroupingConfigResolution(context, ankiConnect, 'isKiku');
}
@@ -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);
+6 -16
View File
@@ -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<string, unknown>,
): 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.