diff --git a/src/config/anki-connect-nplusone-migration.ts b/src/config/anki-connect-nplusone-migration.ts index 17a90d1c..a0317869 100644 --- a/src/config/anki-connect-nplusone-migration.ts +++ b/src/config/anki-connect-nplusone-migration.ts @@ -112,17 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): { if (!key) continue; const valueNode = propertyValue(property); const value = valueNode ? getNodeValue(valueNode) : undefined; - if (key === 'enabled' && typeof value === 'boolean') { - canonicalNPlusOneValues.set(key, value); + if (key === 'enabled') { + if (typeof value === 'boolean') { + canonicalNPlusOneValues.set(key, value); + } else { + canonicalNPlusOneValues.delete(key); + } continue; } - if ( - key === 'minSentenceWords' && - typeof value === 'number' && - Number.isInteger(value) && - value > 0 - ) { - canonicalNPlusOneValues.set(key, value); + if (key === 'minSentenceWords') { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + canonicalNPlusOneValues.set(key, value); + } else { + canonicalNPlusOneValues.delete(key); + } continue; } if (key in LEGACY_N_PLUS_ONE_PATH_MAP) { diff --git a/src/config/config.test.ts b/src/config/config.test.ts index badc8e92..864dcc8b 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2462,6 +2462,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent); }); +test('later invalid duplicate nPlusOne values supersede earlier valid values', () => { + const dir = makeTempDir(); + const configPath = path.join(dir, 'config.jsonc'); + const originalContent = `{ + "ankiConnect": { + "nPlusOne": { + "enabled": true, + "minSentenceWords": 4 + }, + "nPlusOne": { + "enabled": "yes", + "minSentenceWords": "4" + } + } + }`; + fs.writeFileSync(configPath, originalContent, 'utf-8'); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled); + assert.equal( + config.ankiConnect.nPlusOne.minSentenceWords, + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.enabled')); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords')); + assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent); +}); + test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { const dir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 313d48fd..0f7c151e 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -1,8 +1,9 @@ import type { ResolveContext } from './context'; import { initializeAnkiConnectResolution } from './anki-connect/initialize'; +import { applyAnkiKikuResolution } from './anki-connect/kiku'; import { applyAnkiKnownWordsResolution } from './anki-connect/known-words'; import { applyAnkiLegacyResolution } from './anki-connect/legacy'; -import { applyAnkiKikuResolution, applyAnkiModernResolution } from './anki-connect/modern'; +import { applyAnkiModernResolution } from './anki-connect/modern'; import { isObject } from './shared'; export function applyAnkiConnectResolution(context: ResolveContext): void { diff --git a/src/config/resolve/anki-connect/ai.ts b/src/config/resolve/anki-connect/ai.ts new file mode 100644 index 00000000..97aada9d --- /dev/null +++ b/src/config/resolve/anki-connect/ai.ts @@ -0,0 +1,57 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asString, isObject } from '../shared'; + +export function applyAiResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.ai)) { + const aiEnabled = asBoolean(ankiConnect.ai.enabled); + if (aiEnabled !== undefined) { + context.resolved.ankiConnect.ai.enabled = aiEnabled; + } else if (ankiConnect.ai.enabled !== undefined) { + context.warn( + 'ankiConnect.ai.enabled', + ankiConnect.ai.enabled, + context.resolved.ankiConnect.ai.enabled, + 'Expected boolean.', + ); + } + + const aiModel = asString(ankiConnect.ai.model); + if (aiModel !== undefined) { + context.resolved.ankiConnect.ai.model = aiModel; + } else if (ankiConnect.ai.model !== undefined) { + context.warn( + 'ankiConnect.ai.model', + ankiConnect.ai.model, + context.resolved.ankiConnect.ai.model, + 'Expected string.', + ); + } + + const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt); + if (aiSystemPrompt !== undefined) { + context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt; + } else if (ankiConnect.ai.systemPrompt !== undefined) { + context.warn( + 'ankiConnect.ai.systemPrompt', + ankiConnect.ai.systemPrompt, + context.resolved.ankiConnect.ai.systemPrompt, + 'Expected string.', + ); + } + } else { + const aiEnabled = asBoolean(ankiConnect.ai); + if (aiEnabled !== undefined) { + context.resolved.ankiConnect.ai.enabled = aiEnabled; + } else if (ankiConnect.ai !== undefined) { + context.warn( + 'ankiConnect.ai', + ankiConnect.ai, + context.resolved.ankiConnect.ai.enabled, + 'Expected boolean or object.', + ); + } + } +} diff --git a/src/config/resolve/anki-connect/kiku.ts b/src/config/resolve/anki-connect/kiku.ts new file mode 100644 index 00000000..bce4ddb5 --- /dev/null +++ b/src/config/resolve/anki-connect/kiku.ts @@ -0,0 +1,19 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +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; + } +} diff --git a/src/config/resolve/anki-connect/lapis.ts b/src/config/resolve/anki-connect/lapis.ts new file mode 100644 index 00000000..e57d8c14 --- /dev/null +++ b/src/config/resolve/anki-connect/lapis.ts @@ -0,0 +1,58 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asString, isObject } from '../shared'; + +export function applyLapisResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.isLapis)) { + const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled); + if (lapisEnabled !== undefined) { + context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ankiConnect.isLapis.enabled !== undefined) { + context.warn( + 'ankiConnect.isLapis.enabled', + ankiConnect.isLapis.enabled, + context.resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ankiConnect.isLapis.sentenceCardModel !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardModel', + ankiConnect.isLapis.sentenceCardModel, + context.resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ankiConnect.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ankiConnect.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ankiConnect.isLapis !== undefined) { + context.warn( + 'ankiConnect.isLapis', + ankiConnect.isLapis, + context.resolved.ankiConnect.isLapis, + 'Expected object.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-behavior.ts b/src/config/resolve/anki-connect/modern-behavior.ts new file mode 100644 index 00000000..9fd5630f --- /dev/null +++ b/src/config/resolve/anki-connect/modern-behavior.ts @@ -0,0 +1,55 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean } from '../shared'; +import { applyModernValue } from './modern-value'; +import { asNotificationType } from './shared'; + +export function applyModernBehaviorResolution( + context: ResolveContext, + behavior: Record, +): void { + for (const key of [ + 'overwriteAudio', + 'overwriteImage', + 'highlightWord', + 'autoUpdateNewCards', + ] as const) { + applyModernValue( + context, + behavior, + key, + `ankiConnect.behavior.${key}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect.behavior[key], + (value) => { + context.resolved.ankiConnect.behavior[key] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + behavior, + 'mediaInsertMode', + 'ankiConnect.behavior.mediaInsertMode', + (value) => (value === 'append' || value === 'prepend' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode, + (value) => { + context.resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + "Expected 'append' or 'prepend'.", + ); + applyModernValue( + context, + behavior, + 'notificationType', + 'ankiConnect.behavior.notificationType', + asNotificationType, + DEFAULT_CONFIG.ankiConnect.behavior.notificationType, + (value) => { + context.resolved.ankiConnect.behavior.notificationType = value; + }, + "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", + ); +} diff --git a/src/config/resolve/anki-connect/modern-fields.ts b/src/config/resolve/anki-connect/modern-fields.ts new file mode 100644 index 00000000..c8d30ad1 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-fields.ts @@ -0,0 +1,24 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asString } from '../shared'; +import { applyModernValue } from './modern-value'; + +export function applyModernFieldsResolution( + context: ResolveContext, + fields: Record, +): void { + for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) { + applyModernValue( + context, + fields, + key, + `ankiConnect.fields.${key}`, + asString, + DEFAULT_CONFIG.ankiConnect.fields[key], + (value) => { + context.resolved.ankiConnect.fields[key] = value; + }, + 'Expected string.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-media.ts b/src/config/resolve/anki-connect/modern-media.ts new file mode 100644 index 00000000..d391f1c0 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-media.ts @@ -0,0 +1,145 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asBoolean } from '../shared'; +import { + applyModernValue, + asIntegerInRange, + asNonNegativeInteger, + asNonNegativeNumber, + asPositiveNumber, +} from './modern-value'; + +export function applyModernMediaResolution( + context: ResolveContext, + media: Record, +): void { + for (const key of [ + 'generateAudio', + 'generateImage', + 'syncAnimatedImageToWordAudio', + 'normalizeAudio', + 'mirrorMpvVolume', + ] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asBoolean, + DEFAULT_CONFIG.ankiConnect.media[key], + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected boolean.', + ); + } + + applyModernValue( + context, + media, + 'imageType', + 'ankiConnect.media.imageType', + (value) => (value === 'static' || value === 'avif' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.media.imageType, + (value) => { + context.resolved.ankiConnect.media.imageType = value; + }, + "Expected 'static' or 'avif'.", + ); + applyModernValue( + context, + media, + 'imageFormat', + 'ankiConnect.media.imageFormat', + (value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined), + DEFAULT_CONFIG.ankiConnect.media.imageFormat, + (value) => { + context.resolved.ankiConnect.media.imageFormat = value; + }, + "Expected 'jpg', 'png', or 'webp'.", + ); + applyModernValue( + context, + media, + 'imageQuality', + 'ankiConnect.media.imageQuality', + (value) => asIntegerInRange(value, 1, 100), + DEFAULT_CONFIG.ankiConnect.media.imageQuality, + (value) => { + context.resolved.ankiConnect.media.imageQuality = value; + }, + 'Expected integer between 1 and 100.', + ); + + for (const key of [ + 'imageMaxWidth', + 'imageMaxHeight', + 'animatedMaxWidth', + 'animatedMaxHeight', + ] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asNonNegativeInteger, + DEFAULT_CONFIG.ankiConnect.media[key] ?? 0, + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected non-negative integer.', + ); + } + + applyModernValue( + context, + media, + 'animatedFps', + 'ankiConnect.media.animatedFps', + (value) => asIntegerInRange(value, 1, 60), + DEFAULT_CONFIG.ankiConnect.media.animatedFps, + (value) => { + context.resolved.ankiConnect.media.animatedFps = value; + }, + 'Expected integer between 1 and 60.', + ); + applyModernValue( + context, + media, + 'animatedCrf', + 'ankiConnect.media.animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + DEFAULT_CONFIG.ankiConnect.media.animatedCrf, + (value) => { + context.resolved.ankiConnect.media.animatedCrf = value; + }, + 'Expected integer between 0 and 63.', + ); + applyModernValue( + context, + media, + 'audioPadding', + 'ankiConnect.media.audioPadding', + asNonNegativeNumber, + DEFAULT_CONFIG.ankiConnect.media.audioPadding, + (value) => { + context.resolved.ankiConnect.media.audioPadding = value; + }, + 'Expected non-negative number.', + ); + + for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) { + applyModernValue( + context, + media, + key, + `ankiConnect.media.${key}`, + asPositiveNumber, + DEFAULT_CONFIG.ankiConnect.media[key], + (value) => { + context.resolved.ankiConnect.media[key] = value; + }, + 'Expected positive number.', + ); + } +} diff --git a/src/config/resolve/anki-connect/modern-metadata.ts b/src/config/resolve/anki-connect/modern-metadata.ts new file mode 100644 index 00000000..fbb98caf --- /dev/null +++ b/src/config/resolve/anki-connect/modern-metadata.ts @@ -0,0 +1,22 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; +import { asString } from '../shared'; +import { applyModernValue } from './modern-value'; + +export function applyModernMetadataResolution( + context: ResolveContext, + metadata: Record, +): void { + applyModernValue( + context, + metadata, + 'pattern', + 'ankiConnect.metadata.pattern', + asString, + DEFAULT_CONFIG.ankiConnect.metadata.pattern, + (value) => { + context.resolved.ankiConnect.metadata.pattern = value; + }, + 'Expected string.', + ); +} diff --git a/src/config/resolve/anki-connect/modern-value.ts b/src/config/resolve/anki-connect/modern-value.ts new file mode 100644 index 00000000..2a644113 --- /dev/null +++ b/src/config/resolve/anki-connect/modern-value.ts @@ -0,0 +1,46 @@ +import type { ResolveContext } from '../context'; +import { asNumber } from '../shared'; +import { hasOwn } from './shared'; + +export function asIntegerInRange(value: unknown, min: number, max: number): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max + ? parsed + : undefined; +} + +export function asNonNegativeInteger(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function asPositiveNumber(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && parsed > 0 ? parsed : undefined; +} + +export function asNonNegativeNumber(value: unknown): number | undefined { + const parsed = asNumber(value); + return parsed !== undefined && parsed >= 0 ? parsed : undefined; +} + +export function applyModernValue( + context: ResolveContext, + source: Record, + key: string, + path: string, + parse: (value: unknown) => T | undefined, + fallback: T, + apply: (value: T) => void, + message: string, +): void { + if (!hasOwn(source, key)) return; + const raw = source[key]; + const parsed = parse(raw); + if (parsed === undefined) { + apply(fallback); + context.warn(path, raw, fallback, message); + return; + } + apply(parsed); +} diff --git a/src/config/resolve/anki-connect/modern.ts b/src/config/resolve/anki-connect/modern.ts index 51a5fad0..bb240cea 100644 --- a/src/config/resolve/anki-connect/modern.ts +++ b/src/config/resolve/anki-connect/modern.ts @@ -1,270 +1,13 @@ -import { DEFAULT_CONFIG } from '../../definitions'; import type { ResolveContext } from '../context'; -import { asBoolean, asNumber, asString, isObject } from '../shared'; -import { asNotificationType, hasOwn } from './shared'; - -function asIntegerInRange(value: unknown, min: number, max: number): number | undefined { - const parsed = asNumber(value); - return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max - ? parsed - : undefined; -} - -function asNonNegativeInteger(value: unknown): number | undefined { - const parsed = asNumber(value); - return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; -} - -function asPositiveNumber(value: unknown): number | undefined { - const parsed = asNumber(value); - return parsed !== undefined && parsed > 0 ? parsed : undefined; -} - -function asNonNegativeNumber(value: unknown): number | undefined { - const parsed = asNumber(value); - return parsed !== undefined && parsed >= 0 ? parsed : undefined; -} - -function applyModernValue( - context: ResolveContext, - source: Record, - key: string, - path: string, - parse: (value: unknown) => T | undefined, - fallback: T, - apply: (value: T) => void, - message: string, -): void { - if (!hasOwn(source, key)) return; - const raw = source[key]; - const parsed = parse(raw); - if (parsed === undefined) { - apply(fallback); - context.warn(path, raw, fallback, message); - return; - } - apply(parsed); -} - -function applyModernFieldsResolution( - context: ResolveContext, - fields: Record, -): void { - for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) { - applyModernValue( - context, - fields, - key, - `ankiConnect.fields.${key}`, - asString, - DEFAULT_CONFIG.ankiConnect.fields[key], - (value) => { - context.resolved.ankiConnect.fields[key] = value; - }, - 'Expected string.', - ); - } -} - -function applyModernMediaResolution(context: ResolveContext, media: Record): void { - for (const key of [ - 'generateAudio', - 'generateImage', - 'syncAnimatedImageToWordAudio', - 'normalizeAudio', - 'mirrorMpvVolume', - ] as const) { - applyModernValue( - context, - media, - key, - `ankiConnect.media.${key}`, - asBoolean, - DEFAULT_CONFIG.ankiConnect.media[key], - (value) => { - context.resolved.ankiConnect.media[key] = value; - }, - 'Expected boolean.', - ); - } - - applyModernValue( - context, - media, - 'imageType', - 'ankiConnect.media.imageType', - (value) => (value === 'static' || value === 'avif' ? value : undefined), - DEFAULT_CONFIG.ankiConnect.media.imageType, - (value) => { - context.resolved.ankiConnect.media.imageType = value; - }, - "Expected 'static' or 'avif'.", - ); - applyModernValue( - context, - media, - 'imageFormat', - 'ankiConnect.media.imageFormat', - (value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined), - DEFAULT_CONFIG.ankiConnect.media.imageFormat, - (value) => { - context.resolved.ankiConnect.media.imageFormat = value; - }, - "Expected 'jpg', 'png', or 'webp'.", - ); - applyModernValue( - context, - media, - 'imageQuality', - 'ankiConnect.media.imageQuality', - (value) => asIntegerInRange(value, 1, 100), - DEFAULT_CONFIG.ankiConnect.media.imageQuality, - (value) => { - context.resolved.ankiConnect.media.imageQuality = value; - }, - 'Expected integer between 1 and 100.', - ); - - for (const key of [ - 'imageMaxWidth', - 'imageMaxHeight', - 'animatedMaxWidth', - 'animatedMaxHeight', - ] as const) { - applyModernValue( - context, - media, - key, - `ankiConnect.media.${key}`, - asNonNegativeInteger, - DEFAULT_CONFIG.ankiConnect.media[key] ?? 0, - (value) => { - context.resolved.ankiConnect.media[key] = value; - }, - 'Expected non-negative integer.', - ); - } - - applyModernValue( - context, - media, - 'animatedFps', - 'ankiConnect.media.animatedFps', - (value) => asIntegerInRange(value, 1, 60), - DEFAULT_CONFIG.ankiConnect.media.animatedFps, - (value) => { - context.resolved.ankiConnect.media.animatedFps = value; - }, - 'Expected integer between 1 and 60.', - ); - applyModernValue( - context, - media, - 'animatedCrf', - 'ankiConnect.media.animatedCrf', - (value) => asIntegerInRange(value, 0, 63), - DEFAULT_CONFIG.ankiConnect.media.animatedCrf, - (value) => { - context.resolved.ankiConnect.media.animatedCrf = value; - }, - 'Expected integer between 0 and 63.', - ); - applyModernValue( - context, - media, - 'audioPadding', - 'ankiConnect.media.audioPadding', - asNonNegativeNumber, - DEFAULT_CONFIG.ankiConnect.media.audioPadding, - (value) => { - context.resolved.ankiConnect.media.audioPadding = value; - }, - 'Expected non-negative number.', - ); - - for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) { - applyModernValue( - context, - media, - key, - `ankiConnect.media.${key}`, - asPositiveNumber, - DEFAULT_CONFIG.ankiConnect.media[key], - (value) => { - context.resolved.ankiConnect.media[key] = value; - }, - 'Expected positive number.', - ); - } -} - -function applyModernBehaviorResolution( - context: ResolveContext, - behavior: Record, -): void { - for (const key of [ - 'overwriteAudio', - 'overwriteImage', - 'highlightWord', - 'autoUpdateNewCards', - ] as const) { - applyModernValue( - context, - behavior, - key, - `ankiConnect.behavior.${key}`, - asBoolean, - DEFAULT_CONFIG.ankiConnect.behavior[key], - (value) => { - context.resolved.ankiConnect.behavior[key] = value; - }, - 'Expected boolean.', - ); - } - - applyModernValue( - context, - behavior, - 'mediaInsertMode', - 'ankiConnect.behavior.mediaInsertMode', - (value) => (value === 'append' || value === 'prepend' ? value : undefined), - DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode, - (value) => { - context.resolved.ankiConnect.behavior.mediaInsertMode = value; - }, - "Expected 'append' or 'prepend'.", - ); - applyModernValue( - context, - behavior, - 'notificationType', - 'ankiConnect.behavior.notificationType', - asNotificationType, - DEFAULT_CONFIG.ankiConnect.behavior.notificationType, - (value) => { - context.resolved.ankiConnect.behavior.notificationType = value; - }, - "Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.", - ); -} - -function applyModernMetadataResolution( - context: ResolveContext, - metadata: Record, -): void { - applyModernValue( - context, - metadata, - 'pattern', - 'ankiConnect.metadata.pattern', - asString, - DEFAULT_CONFIG.ankiConnect.metadata.pattern, - (value) => { - context.resolved.ankiConnect.metadata.pattern = value; - }, - 'Expected string.', - ); -} +import { isObject } from '../shared'; +import { applyAiResolution } from './ai'; +import { applyLapisResolution } from './lapis'; +import { applyModernBehaviorResolution } from './modern-behavior'; +import { applyModernFieldsResolution } from './modern-fields'; +import { applyModernMediaResolution } from './modern-media'; +import { applyModernMetadataResolution } from './modern-metadata'; +import { applyProxyResolution } from './proxy'; +import { applyTagsResolution } from './tags'; export function applyAnkiModernResolution( context: ResolveContext, @@ -274,228 +17,13 @@ export function applyAnkiModernResolution( ): void { const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {}; const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {}; + applyModernFieldsResolution(context, fields); applyModernMediaResolution(context, media); applyModernBehaviorResolution(context, behavior); applyModernMetadataResolution(context, metadata); - applyLapisResolution(context, ankiConnect); applyProxyResolution(context, ankiConnect); applyAiResolution(context, ankiConnect); applyTagsResolution(context, ankiConnect); } - -function applyLapisResolution(context: ResolveContext, ankiConnect: Record): void { - if (isObject(ankiConnect.isLapis)) { - const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled); - if (lapisEnabled !== undefined) { - context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; - } else if (ankiConnect.isLapis.enabled !== undefined) { - context.warn( - 'ankiConnect.isLapis.enabled', - ankiConnect.isLapis.enabled, - context.resolved.ankiConnect.isLapis.enabled, - 'Expected boolean.', - ); - } - - const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel); - if (sentenceCardModel !== undefined) { - context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; - } else if (ankiConnect.isLapis.sentenceCardModel !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardModel', - ankiConnect.isLapis.sentenceCardModel, - context.resolved.ankiConnect.isLapis.sentenceCardModel, - 'Expected string.', - ); - } - - if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardSentenceField', - ankiConnect.isLapis.sentenceCardSentenceField, - 'Sentence', - 'Deprecated key; sentence-card sentence field is fixed to Sentence.', - ); - } - - if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) { - context.warn( - 'ankiConnect.isLapis.sentenceCardAudioField', - ankiConnect.isLapis.sentenceCardAudioField, - 'SentenceAudio', - 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', - ); - } - } else if (ankiConnect.isLapis !== undefined) { - context.warn( - 'ankiConnect.isLapis', - ankiConnect.isLapis, - context.resolved.ankiConnect.isLapis, - 'Expected object.', - ); - } -} - -function applyProxyResolution(context: ResolveContext, ankiConnect: Record): void { - if (isObject(ankiConnect.proxy)) { - const proxy = ankiConnect.proxy; - const proxyEnabled = asBoolean(proxy.enabled); - if (proxyEnabled !== undefined) { - context.resolved.ankiConnect.proxy.enabled = proxyEnabled; - } else if (proxy.enabled !== undefined) { - context.warn( - 'ankiConnect.proxy.enabled', - proxy.enabled, - context.resolved.ankiConnect.proxy.enabled, - 'Expected boolean.', - ); - } - - const proxyHost = asString(proxy.host); - if (proxyHost !== undefined && proxyHost.trim().length > 0) { - context.resolved.ankiConnect.proxy.host = proxyHost.trim(); - } else if (proxy.host !== undefined) { - context.warn( - 'ankiConnect.proxy.host', - proxy.host, - context.resolved.ankiConnect.proxy.host, - 'Expected non-empty string.', - ); - } - - const proxyUpstreamUrl = asString(proxy.upstreamUrl); - if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) { - context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim(); - } else if (proxy.upstreamUrl !== undefined) { - context.warn( - 'ankiConnect.proxy.upstreamUrl', - proxy.upstreamUrl, - context.resolved.ankiConnect.proxy.upstreamUrl, - 'Expected non-empty string.', - ); - } - - const proxyPort = asNumber(proxy.port); - if ( - proxyPort !== undefined && - Number.isInteger(proxyPort) && - proxyPort >= 1 && - proxyPort <= 65535 - ) { - context.resolved.ankiConnect.proxy.port = proxyPort; - } else if (proxy.port !== undefined) { - context.warn( - 'ankiConnect.proxy.port', - proxy.port, - context.resolved.ankiConnect.proxy.port, - 'Expected integer between 1 and 65535.', - ); - } - } else if (ankiConnect.proxy !== undefined) { - context.warn( - 'ankiConnect.proxy', - ankiConnect.proxy, - context.resolved.ankiConnect.proxy, - 'Expected object.', - ); - } -} - -function applyAiResolution(context: ResolveContext, ankiConnect: Record): void { - if (isObject(ankiConnect.ai)) { - const aiEnabled = asBoolean(ankiConnect.ai.enabled); - if (aiEnabled !== undefined) { - context.resolved.ankiConnect.ai.enabled = aiEnabled; - } else if (ankiConnect.ai.enabled !== undefined) { - context.warn( - 'ankiConnect.ai.enabled', - ankiConnect.ai.enabled, - context.resolved.ankiConnect.ai.enabled, - 'Expected boolean.', - ); - } - - const aiModel = asString(ankiConnect.ai.model); - if (aiModel !== undefined) { - context.resolved.ankiConnect.ai.model = aiModel; - } else if (ankiConnect.ai.model !== undefined) { - context.warn( - 'ankiConnect.ai.model', - ankiConnect.ai.model, - context.resolved.ankiConnect.ai.model, - 'Expected string.', - ); - } - - const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt); - if (aiSystemPrompt !== undefined) { - context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt; - } else if (ankiConnect.ai.systemPrompt !== undefined) { - context.warn( - 'ankiConnect.ai.systemPrompt', - ankiConnect.ai.systemPrompt, - context.resolved.ankiConnect.ai.systemPrompt, - 'Expected string.', - ); - } - } else { - const aiEnabled = asBoolean(ankiConnect.ai); - if (aiEnabled !== undefined) { - context.resolved.ankiConnect.ai.enabled = aiEnabled; - } else if (ankiConnect.ai !== undefined) { - context.warn( - 'ankiConnect.ai', - ankiConnect.ai, - context.resolved.ankiConnect.ai.enabled, - 'Expected boolean or object.', - ); - } - } -} - -function applyTagsResolution(context: ResolveContext, ankiConnect: Record): void { - if (Array.isArray(ankiConnect.tags)) { - const normalizedTags = ankiConnect.tags - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - if (normalizedTags.length === ankiConnect.tags.length) { - context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; - } else { - context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - context.warn( - 'ankiConnect.tags', - ankiConnect.tags, - context.resolved.ankiConnect.tags, - 'Expected an array of non-empty strings.', - ); - } - } else if (ankiConnect.tags !== undefined) { - context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; - context.warn( - 'ankiConnect.tags', - ankiConnect.tags, - context.resolved.ankiConnect.tags, - 'Expected an array of strings.', - ); - } -} - -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; - } -} diff --git a/src/config/resolve/anki-connect/proxy.ts b/src/config/resolve/anki-connect/proxy.ts new file mode 100644 index 00000000..44830988 --- /dev/null +++ b/src/config/resolve/anki-connect/proxy.ts @@ -0,0 +1,70 @@ +import type { ResolveContext } from '../context'; +import { asBoolean, asNumber, asString, isObject } from '../shared'; + +export function applyProxyResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (isObject(ankiConnect.proxy)) { + const proxy = ankiConnect.proxy; + const proxyEnabled = asBoolean(proxy.enabled); + if (proxyEnabled !== undefined) { + context.resolved.ankiConnect.proxy.enabled = proxyEnabled; + } else if (proxy.enabled !== undefined) { + context.warn( + 'ankiConnect.proxy.enabled', + proxy.enabled, + context.resolved.ankiConnect.proxy.enabled, + 'Expected boolean.', + ); + } + + const proxyHost = asString(proxy.host); + if (proxyHost !== undefined && proxyHost.trim().length > 0) { + context.resolved.ankiConnect.proxy.host = proxyHost.trim(); + } else if (proxy.host !== undefined) { + context.warn( + 'ankiConnect.proxy.host', + proxy.host, + context.resolved.ankiConnect.proxy.host, + 'Expected non-empty string.', + ); + } + + const proxyUpstreamUrl = asString(proxy.upstreamUrl); + if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) { + context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim(); + } else if (proxy.upstreamUrl !== undefined) { + context.warn( + 'ankiConnect.proxy.upstreamUrl', + proxy.upstreamUrl, + context.resolved.ankiConnect.proxy.upstreamUrl, + 'Expected non-empty string.', + ); + } + + const proxyPort = asNumber(proxy.port); + if ( + proxyPort !== undefined && + Number.isInteger(proxyPort) && + proxyPort >= 1 && + proxyPort <= 65535 + ) { + context.resolved.ankiConnect.proxy.port = proxyPort; + } else if (proxy.port !== undefined) { + context.warn( + 'ankiConnect.proxy.port', + proxy.port, + context.resolved.ankiConnect.proxy.port, + 'Expected integer between 1 and 65535.', + ); + } + } else if (ankiConnect.proxy !== undefined) { + context.warn( + 'ankiConnect.proxy', + ankiConnect.proxy, + context.resolved.ankiConnect.proxy, + 'Expected object.', + ); + } +} diff --git a/src/config/resolve/anki-connect/tags.ts b/src/config/resolve/anki-connect/tags.ts new file mode 100644 index 00000000..3e204f98 --- /dev/null +++ b/src/config/resolve/anki-connect/tags.ts @@ -0,0 +1,33 @@ +import { DEFAULT_CONFIG } from '../../definitions'; +import type { ResolveContext } from '../context'; + +export function applyTagsResolution( + context: ResolveContext, + ankiConnect: Record, +): void { + if (Array.isArray(ankiConnect.tags)) { + const normalizedTags = ankiConnect.tags + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalizedTags.length === ankiConnect.tags.length) { + context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ankiConnect.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ankiConnect.tags !== undefined) { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ankiConnect.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of strings.', + ); + } +}