mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
fix(config): validate AnkiConnect and field grouping settings (#257)
This commit is contained in:
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user