From 54109deb94dab60f2b52cc412b01caac15673d09 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 21 Feb 2026 02:32:28 -0800 Subject: [PATCH] refactor(config): extract resolve domain modules and seam tests --- src/config/resolve/anki-connect.test.ts | 68 ++ src/config/resolve/anki-connect.ts | 728 ++++++++++++++++++++++ src/config/resolve/context.ts | 30 + src/config/resolve/core-domains.ts | 179 ++++++ src/config/resolve/immersion-tracking.ts | 173 +++++ src/config/resolve/integrations.ts | 92 +++ src/config/resolve/jellyfin.test.ts | 16 + src/config/resolve/shared.ts | 38 ++ src/config/resolve/subtitle-domains.ts | 225 +++++++ src/config/resolve/subtitle-style.test.ts | 29 + src/config/resolve/top-level.ts | 28 + 11 files changed, 1606 insertions(+) create mode 100644 src/config/resolve/anki-connect.test.ts create mode 100644 src/config/resolve/anki-connect.ts create mode 100644 src/config/resolve/context.ts create mode 100644 src/config/resolve/core-domains.ts create mode 100644 src/config/resolve/immersion-tracking.ts create mode 100644 src/config/resolve/integrations.ts create mode 100644 src/config/resolve/jellyfin.test.ts create mode 100644 src/config/resolve/shared.ts create mode 100644 src/config/resolve/subtitle-domains.ts create mode 100644 src/config/resolve/subtitle-style.test.ts create mode 100644 src/config/resolve/top-level.ts diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts new file mode 100644 index 0000000..0b7a1cd --- /dev/null +++ b/src/config/resolve/anki-connect.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; +import { createWarningCollector } from '../warnings'; +import { applyAnkiConnectResolution } from './anki-connect'; +import type { ResolveContext } from './context'; + +function makeContext(ankiConnect: unknown): { + context: ResolveContext; + warnings: ReturnType['warnings']; +} { + const { warnings, warn } = createWarningCollector(); + const resolved = deepCloneConfig(DEFAULT_CONFIG); + const context = { + src: { ankiConnect }, + resolved, + warn, + } as unknown as ResolveContext; + + return { context, warnings }; +} + +test('modern invalid nPlusOne.highlightEnabled warns modern key and does not fallback to legacy', () => { + const { context, warnings } = makeContext({ + behavior: { nPlusOneHighlightEnabled: true }, + nPlusOne: { highlightEnabled: 'yes' }, + }); + + applyAnkiConnectResolution(context); + + assert.equal( + context.resolved.ankiConnect.nPlusOne.highlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.highlightEnabled')); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.behavior.nPlusOneHighlightEnabled'), + false, + ); +}); + +test('normalizes ankiConnect tags by trimming and deduping', () => { + const { context, warnings } = makeContext({ + tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '], + }); + + applyAnkiConnectResolution(context); + + assert.deepEqual(context.resolved.ankiConnect.tags, ['SubMiner', 'Mining']); + assert.equal( + warnings.some((warning) => warning.path === 'ankiConnect.tags'), + false, + ); +}); + +test('warns and falls back for invalid nPlusOne.decks entries', () => { + const { context, warnings } = makeContext({ + nPlusOne: { decks: ['Core Deck', 123] }, + }); + + applyAnkiConnectResolution(context); + + assert.deepEqual( + context.resolved.ankiConnect.nPlusOne.decks, + DEFAULT_CONFIG.ankiConnect.nPlusOne.decks, + ); + assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.decks')); +}); diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts new file mode 100644 index 0000000..f88d7e6 --- /dev/null +++ b/src/config/resolve/anki-connect.ts @@ -0,0 +1,728 @@ +import { DEFAULT_CONFIG } from '../definitions'; +import type { ResolveContext } from './context'; +import { asBoolean, asColor, asNumber, asString, isObject } from './shared'; + +export function applyAnkiConnectResolution(context: ResolveContext): void { + if (!isObject(context.src.ankiConnect)) { + return; + } + + const ac = context.src.ankiConnect; + const behavior = isObject(ac.behavior) ? (ac.behavior as Record) : {}; + const fields = isObject(ac.fields) ? (ac.fields as Record) : {}; + const media = isObject(ac.media) ? (ac.media as Record) : {}; + const metadata = isObject(ac.metadata) ? (ac.metadata as Record) : {}; + 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) { + context.warn( + 'ankiConnect.openRouter', + ac.openRouter, + context.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)), + ); + + context.resolved.ankiConnect = { + ...context.resolved.ankiConnect, + ...(isObject(ankiConnectWithoutLegacy) + ? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>) + : {}), + fields: { + ...context.resolved.ankiConnect.fields, + ...(isObject(ac.fields) + ? (ac.fields as (typeof context.resolved)['ankiConnect']['fields']) + : {}), + }, + ai: { + ...context.resolved.ankiConnect.ai, + ...(aiSource as (typeof context.resolved)['ankiConnect']['ai']), + }, + media: { + ...context.resolved.ankiConnect.media, + ...(isObject(ac.media) + ? (ac.media as (typeof context.resolved)['ankiConnect']['media']) + : {}), + }, + behavior: { + ...context.resolved.ankiConnect.behavior, + ...(isObject(ac.behavior) + ? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior']) + : {}), + }, + metadata: { + ...context.resolved.ankiConnect.metadata, + ...(isObject(ac.metadata) + ? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata']) + : {}), + }, + isLapis: { + ...context.resolved.ankiConnect.isLapis, + }, + isKiku: { + ...context.resolved.ankiConnect.isKiku, + ...(isObject(ac.isKiku) + ? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku']) + : {}), + }, + }; + + if (isObject(ac.isLapis)) { + const lapisEnabled = asBoolean(ac.isLapis.enabled); + if (lapisEnabled !== undefined) { + context.resolved.ankiConnect.isLapis.enabled = lapisEnabled; + } else if (ac.isLapis.enabled !== undefined) { + context.warn( + 'ankiConnect.isLapis.enabled', + ac.isLapis.enabled, + context.resolved.ankiConnect.isLapis.enabled, + 'Expected boolean.', + ); + } + + const sentenceCardModel = asString(ac.isLapis.sentenceCardModel); + if (sentenceCardModel !== undefined) { + context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel; + } else if (ac.isLapis.sentenceCardModel !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardModel', + ac.isLapis.sentenceCardModel, + context.resolved.ankiConnect.isLapis.sentenceCardModel, + 'Expected string.', + ); + } + + if (ac.isLapis.sentenceCardSentenceField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardSentenceField', + ac.isLapis.sentenceCardSentenceField, + 'Sentence', + 'Deprecated key; sentence-card sentence field is fixed to Sentence.', + ); + } + + if (ac.isLapis.sentenceCardAudioField !== undefined) { + context.warn( + 'ankiConnect.isLapis.sentenceCardAudioField', + ac.isLapis.sentenceCardAudioField, + 'SentenceAudio', + 'Deprecated key; sentence-card audio field is fixed to SentenceAudio.', + ); + } + } else if (ac.isLapis !== undefined) { + context.warn( + 'ankiConnect.isLapis', + ac.isLapis, + context.resolved.ankiConnect.isLapis, + 'Expected object.', + ); + } + + if (Array.isArray(ac.tags)) { + const normalizedTags = ac.tags + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + if (normalizedTags.length === ac.tags.length) { + context.resolved.ankiConnect.tags = [...new Set(normalizedTags)]; + } else { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ac.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of non-empty strings.', + ); + } + } else if (ac.tags !== undefined) { + context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags; + context.warn( + 'ankiConnect.tags', + ac.tags, + context.resolved.ankiConnect.tags, + 'Expected an array of strings.', + ); + } + + const legacy = ac as Record; + const hasOwn = (obj: Record, 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 = ( + 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) { + context.warn(`ankiConnect.${key}`, value, fallback, message); + return; + } + apply(parsed); + }; + + if (!hasOwn(fields, 'audio')) { + mapLegacy( + 'audioField', + asString, + (value) => { + context.resolved.ankiConnect.fields.audio = value; + }, + context.resolved.ankiConnect.fields.audio, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'image')) { + mapLegacy( + 'imageField', + asString, + (value) => { + context.resolved.ankiConnect.fields.image = value; + }, + context.resolved.ankiConnect.fields.image, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'sentence')) { + mapLegacy( + 'sentenceField', + asString, + (value) => { + context.resolved.ankiConnect.fields.sentence = value; + }, + context.resolved.ankiConnect.fields.sentence, + 'Expected string.', + ); + } + if (!hasOwn(fields, 'miscInfo')) { + mapLegacy( + 'miscInfoField', + asString, + (value) => { + context.resolved.ankiConnect.fields.miscInfo = value; + }, + context.resolved.ankiConnect.fields.miscInfo, + 'Expected string.', + ); + } + if (!hasOwn(metadata, 'pattern')) { + mapLegacy( + 'miscInfoPattern', + asString, + (value) => { + context.resolved.ankiConnect.metadata.pattern = value; + }, + context.resolved.ankiConnect.metadata.pattern, + 'Expected string.', + ); + } + if (!hasOwn(media, 'generateAudio')) { + mapLegacy( + 'generateAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateAudio = value; + }, + context.resolved.ankiConnect.media.generateAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'generateImage')) { + mapLegacy( + 'generateImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.media.generateImage = value; + }, + context.resolved.ankiConnect.media.generateImage, + 'Expected boolean.', + ); + } + if (!hasOwn(media, 'imageType')) { + mapLegacy( + 'imageType', + asImageType, + (value) => { + context.resolved.ankiConnect.media.imageType = value; + }, + context.resolved.ankiConnect.media.imageType, + "Expected 'static' or 'avif'.", + ); + } + if (!hasOwn(media, 'imageFormat')) { + mapLegacy( + 'imageFormat', + asImageFormat, + (value) => { + context.resolved.ankiConnect.media.imageFormat = value; + }, + context.resolved.ankiConnect.media.imageFormat, + "Expected 'jpg', 'png', or 'webp'.", + ); + } + if (!hasOwn(media, 'imageQuality')) { + mapLegacy( + 'imageQuality', + (value) => asIntegerInRange(value, 1, 100), + (value) => { + context.resolved.ankiConnect.media.imageQuality = value; + }, + context.resolved.ankiConnect.media.imageQuality, + 'Expected integer between 1 and 100.', + ); + } + if (!hasOwn(media, 'imageMaxWidth')) { + mapLegacy( + 'imageMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxWidth = value; + }, + context.resolved.ankiConnect.media.imageMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'imageMaxHeight')) { + mapLegacy( + 'imageMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.imageMaxHeight = value; + }, + context.resolved.ankiConnect.media.imageMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedFps')) { + mapLegacy( + 'animatedFps', + (value) => asIntegerInRange(value, 1, 60), + (value) => { + context.resolved.ankiConnect.media.animatedFps = value; + }, + context.resolved.ankiConnect.media.animatedFps, + 'Expected integer between 1 and 60.', + ); + } + if (!hasOwn(media, 'animatedMaxWidth')) { + mapLegacy( + 'animatedMaxWidth', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxWidth = value; + }, + context.resolved.ankiConnect.media.animatedMaxWidth, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedMaxHeight')) { + mapLegacy( + 'animatedMaxHeight', + asPositiveInteger, + (value) => { + context.resolved.ankiConnect.media.animatedMaxHeight = value; + }, + context.resolved.ankiConnect.media.animatedMaxHeight, + 'Expected positive integer.', + ); + } + if (!hasOwn(media, 'animatedCrf')) { + mapLegacy( + 'animatedCrf', + (value) => asIntegerInRange(value, 0, 63), + (value) => { + context.resolved.ankiConnect.media.animatedCrf = value; + }, + context.resolved.ankiConnect.media.animatedCrf, + 'Expected integer between 0 and 63.', + ); + } + if (!hasOwn(media, 'audioPadding')) { + mapLegacy( + 'audioPadding', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.audioPadding = value; + }, + context.resolved.ankiConnect.media.audioPadding, + 'Expected non-negative number.', + ); + } + if (!hasOwn(media, 'fallbackDuration')) { + mapLegacy( + 'fallbackDuration', + asPositiveNumber, + (value) => { + context.resolved.ankiConnect.media.fallbackDuration = value; + }, + context.resolved.ankiConnect.media.fallbackDuration, + 'Expected positive number.', + ); + } + if (!hasOwn(media, 'maxMediaDuration')) { + mapLegacy( + 'maxMediaDuration', + asNonNegativeNumber, + (value) => { + context.resolved.ankiConnect.media.maxMediaDuration = value; + }, + context.resolved.ankiConnect.media.maxMediaDuration, + 'Expected non-negative number.', + ); + } + if (!hasOwn(behavior, 'overwriteAudio')) { + mapLegacy( + 'overwriteAudio', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteAudio = value; + }, + context.resolved.ankiConnect.behavior.overwriteAudio, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'overwriteImage')) { + mapLegacy( + 'overwriteImage', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.overwriteImage = value; + }, + context.resolved.ankiConnect.behavior.overwriteImage, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'mediaInsertMode')) { + mapLegacy( + 'mediaInsertMode', + asMediaInsertMode, + (value) => { + context.resolved.ankiConnect.behavior.mediaInsertMode = value; + }, + context.resolved.ankiConnect.behavior.mediaInsertMode, + "Expected 'append' or 'prepend'.", + ); + } + if (!hasOwn(behavior, 'highlightWord')) { + mapLegacy( + 'highlightWord', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.highlightWord = value; + }, + context.resolved.ankiConnect.behavior.highlightWord, + 'Expected boolean.', + ); + } + if (!hasOwn(behavior, 'notificationType')) { + mapLegacy( + 'notificationType', + asNotificationType, + (value) => { + context.resolved.ankiConnect.behavior.notificationType = value; + }, + context.resolved.ankiConnect.behavior.notificationType, + "Expected 'osd', 'system', 'both', or 'none'.", + ); + } + if (!hasOwn(behavior, 'autoUpdateNewCards')) { + mapLegacy( + 'autoUpdateNewCards', + asBoolean, + (value) => { + context.resolved.ankiConnect.behavior.autoUpdateNewCards = value; + }, + context.resolved.ankiConnect.behavior.autoUpdateNewCards, + 'Expected boolean.', + ); + } + + const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; + + const nPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled); + if (nPlusOneHighlightEnabled !== undefined) { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = nPlusOneHighlightEnabled; + } else if (nPlusOneConfig.highlightEnabled !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.highlightEnabled', + nPlusOneConfig.highlightEnabled, + context.resolved.ankiConnect.nPlusOne.highlightEnabled, + 'Expected boolean.', + ); + context.resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } else { + const legacyNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled); + if (legacyNPlusOneHighlightEnabled !== undefined) { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = legacyNPlusOneHighlightEnabled; + context.warn( + 'ankiConnect.behavior.nPlusOneHighlightEnabled', + behavior.nPlusOneHighlightEnabled, + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.highlightEnabled', + ); + } else { + context.resolved.ankiConnect.nPlusOne.highlightEnabled = + DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled; + } + } + + const nPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes); + const hasValidNPlusOneRefreshMinutes = + nPlusOneRefreshMinutes !== undefined && + Number.isInteger(nPlusOneRefreshMinutes) && + nPlusOneRefreshMinutes > 0; + if (nPlusOneRefreshMinutes !== undefined) { + if (hasValidNPlusOneRefreshMinutes) { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes; + } else { + context.warn( + 'ankiConnect.nPlusOne.refreshMinutes', + nPlusOneConfig.refreshMinutes, + context.resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) { + const legacyNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes); + const hasValidLegacyRefreshMinutes = + legacyNPlusOneRefreshMinutes !== undefined && + Number.isInteger(legacyNPlusOneRefreshMinutes) && + legacyNPlusOneRefreshMinutes > 0; + if (hasValidLegacyRefreshMinutes) { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = legacyNPlusOneRefreshMinutes; + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.refreshMinutes', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneRefreshMinutes', + behavior.nPlusOneRefreshMinutes, + context.resolved.ankiConnect.nPlusOne.refreshMinutes, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + } else { + context.resolved.ankiConnect.nPlusOne.refreshMinutes = + DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes; + } + + const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords); + const hasValidNPlusOneMinSentenceWords = + nPlusOneMinSentenceWords !== undefined && + Number.isInteger(nPlusOneMinSentenceWords) && + nPlusOneMinSentenceWords > 0; + if (nPlusOneMinSentenceWords !== undefined) { + if (hasValidNPlusOneMinSentenceWords) { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords; + } else { + context.warn( + 'ankiConnect.nPlusOne.minSentenceWords', + nPlusOneConfig.minSentenceWords, + context.resolved.ankiConnect.nPlusOne.minSentenceWords, + 'Expected a positive integer.', + ); + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + } else { + context.resolved.ankiConnect.nPlusOne.minSentenceWords = + DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords; + } + + const nPlusOneMatchMode = asString(nPlusOneConfig.matchMode); + const legacyNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode); + const hasValidNPlusOneMatchMode = + nPlusOneMatchMode === 'headword' || nPlusOneMatchMode === 'surface'; + const hasValidLegacyMatchMode = + legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface'; + if (hasValidNPlusOneMatchMode) { + context.resolved.ankiConnect.nPlusOne.matchMode = nPlusOneMatchMode; + } else if (nPlusOneMatchMode !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.matchMode', + nPlusOneConfig.matchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } else if (legacyNPlusOneMatchMode !== undefined) { + if (hasValidLegacyMatchMode) { + context.resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode; + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, + 'Legacy key is deprecated; use ankiConnect.nPlusOne.matchMode', + ); + } else { + context.warn( + 'ankiConnect.behavior.nPlusOneMatchMode', + behavior.nPlusOneMatchMode, + context.resolved.ankiConnect.nPlusOne.matchMode, + "Expected 'headword' or 'surface'.", + ); + context.resolved.ankiConnect.nPlusOne.matchMode = + DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + } else { + context.resolved.ankiConnect.nPlusOne.matchMode = DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode; + } + + const nPlusOneDecks = nPlusOneConfig.decks; + if (Array.isArray(nPlusOneDecks)) { + const normalizedDecks = nPlusOneDecks + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (normalizedDecks.length === nPlusOneDecks.length) { + context.resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)]; + } else if (nPlusOneDecks.length > 0) { + context.warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + context.resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + } else { + context.resolved.ankiConnect.nPlusOne.decks = []; + } + } else if (nPlusOneDecks !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.decks', + nPlusOneDecks, + context.resolved.ankiConnect.nPlusOne.decks, + 'Expected an array of strings.', + ); + context.resolved.ankiConnect.nPlusOne.decks = []; + } + + const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); + if (nPlusOneHighlightColor !== undefined) { + context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor; + } else if (nPlusOneConfig.nPlusOne !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.nPlusOne', + nPlusOneConfig.nPlusOne, + context.resolved.ankiConnect.nPlusOne.nPlusOne, + 'Expected a hex color value.', + ); + context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; + } + + const nPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); + if (nPlusOneKnownWordColor !== undefined) { + context.resolved.ankiConnect.nPlusOne.knownWord = nPlusOneKnownWordColor; + } else if (nPlusOneConfig.knownWord !== undefined) { + context.warn( + 'ankiConnect.nPlusOne.knownWord', + nPlusOneConfig.knownWord, + context.resolved.ankiConnect.nPlusOne.knownWord, + 'Expected a hex color value.', + ); + context.resolved.ankiConnect.nPlusOne.knownWord = DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord; + } + + 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/context.ts b/src/config/resolve/context.ts new file mode 100644 index 0000000..abae21d --- /dev/null +++ b/src/config/resolve/context.ts @@ -0,0 +1,30 @@ +import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types'; +import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions'; +import { createWarningCollector } from '../warnings'; +import { isObject } from './shared'; + +export interface ResolveContext { + src: Record; + resolved: ResolvedConfig; + warn(path: string, value: unknown, fallback: unknown, message: string): void; +} + +export type ResolveConfigApplier = (context: ResolveContext) => void; + +export function createResolveContext(raw: RawConfig): { + context: ResolveContext; + warnings: ConfigValidationWarning[]; +} { + const resolved = deepCloneConfig(DEFAULT_CONFIG); + const { warnings, warn } = createWarningCollector(); + const src = isObject(raw) ? raw : {}; + + return { + context: { + src, + resolved, + warn, + }, + warnings, + }; +} diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts new file mode 100644 index 0000000..f26026b --- /dev/null +++ b/src/config/resolve/core-domains.ts @@ -0,0 +1,179 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyCoreDomainConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.texthooker)) { + const openBrowser = asBoolean(src.texthooker.openBrowser); + if (openBrowser !== undefined) { + resolved.texthooker.openBrowser = openBrowser; + } else if (src.texthooker.openBrowser !== undefined) { + warn( + 'texthooker.openBrowser', + src.texthooker.openBrowser, + resolved.texthooker.openBrowser, + 'Expected boolean.', + ); + } + } + + if (isObject(src.websocket)) { + const enabled = src.websocket.enabled; + if (enabled === 'auto' || enabled === true || enabled === false) { + resolved.websocket.enabled = enabled; + } else if (enabled !== undefined) { + warn( + 'websocket.enabled', + enabled, + resolved.websocket.enabled, + "Expected true, false, or 'auto'.", + ); + } + + const port = asNumber(src.websocket.port); + if (port !== undefined && port > 0 && port <= 65535) { + resolved.websocket.port = Math.floor(port); + } else if (src.websocket.port !== undefined) { + warn( + 'websocket.port', + src.websocket.port, + resolved.websocket.port, + 'Expected integer between 1 and 65535.', + ); + } + } + + if (isObject(src.logging)) { + const logLevel = asString(src.logging.level); + if ( + logLevel === 'debug' || + logLevel === 'info' || + logLevel === 'warn' || + logLevel === 'error' + ) { + resolved.logging.level = logLevel; + } else if (src.logging.level !== undefined) { + warn( + 'logging.level', + src.logging.level, + resolved.logging.level, + 'Expected debug, info, warn, or error.', + ); + } + } + + if (Array.isArray(src.keybindings)) { + resolved.keybindings = src.keybindings.filter( + (entry): entry is { key: string; command: (string | number)[] | null } => { + if (!isObject(entry)) return false; + if (typeof entry.key !== 'string') return false; + if (entry.command === null) return true; + return Array.isArray(entry.command); + }, + ); + } + + if (isObject(src.shortcuts)) { + const shortcutKeys = [ + 'toggleVisibleOverlayGlobal', + 'toggleInvisibleOverlayGlobal', + 'copySubtitle', + 'copySubtitleMultiple', + 'updateLastCardFromClipboard', + 'triggerFieldGrouping', + 'triggerSubsync', + 'mineSentence', + 'mineSentenceMultiple', + 'toggleSecondarySub', + 'markAudioCard', + 'openRuntimeOptions', + 'openJimaku', + ] as const; + + for (const key of shortcutKeys) { + const value = src.shortcuts[key]; + if (typeof value === 'string' || value === null) { + resolved.shortcuts[key] = value as (typeof resolved.shortcuts)[typeof key]; + } else if (value !== undefined) { + warn(`shortcuts.${key}`, value, resolved.shortcuts[key], 'Expected string or null.'); + } + } + + const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs); + if (timeout !== undefined && timeout > 0) { + resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout); + } else if (src.shortcuts.multiCopyTimeoutMs !== undefined) { + warn( + 'shortcuts.multiCopyTimeoutMs', + src.shortcuts.multiCopyTimeoutMs, + resolved.shortcuts.multiCopyTimeoutMs, + 'Expected positive number.', + ); + } + } + + if (isObject(src.invisibleOverlay)) { + const startupVisibility = src.invisibleOverlay.startupVisibility; + if ( + startupVisibility === 'platform-default' || + startupVisibility === 'visible' || + startupVisibility === 'hidden' + ) { + resolved.invisibleOverlay.startupVisibility = startupVisibility; + } else if (startupVisibility !== undefined) { + warn( + 'invisibleOverlay.startupVisibility', + startupVisibility, + resolved.invisibleOverlay.startupVisibility, + 'Expected platform-default, visible, or hidden.', + ); + } + } + + if (isObject(src.secondarySub)) { + if (Array.isArray(src.secondarySub.secondarySubLanguages)) { + resolved.secondarySub.secondarySubLanguages = src.secondarySub.secondarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } + const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub); + if (autoLoad !== undefined) { + resolved.secondarySub.autoLoadSecondarySub = autoLoad; + } + const defaultMode = src.secondarySub.defaultMode; + if (defaultMode === 'hidden' || defaultMode === 'visible' || defaultMode === 'hover') { + resolved.secondarySub.defaultMode = defaultMode; + } else if (defaultMode !== undefined) { + warn( + 'secondarySub.defaultMode', + defaultMode, + resolved.secondarySub.defaultMode, + 'Expected hidden, visible, or hover.', + ); + } + } + + if (isObject(src.subsync)) { + const mode = src.subsync.defaultMode; + if (mode === 'auto' || mode === 'manual') { + resolved.subsync.defaultMode = mode; + } else if (mode !== undefined) { + warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.'); + } + + const alass = asString(src.subsync.alass_path); + if (alass !== undefined) resolved.subsync.alass_path = alass; + const ffsubsync = asString(src.subsync.ffsubsync_path); + if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; + const ffmpeg = asString(src.subsync.ffmpeg_path); + if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; + } + + if (isObject(src.subtitlePosition)) { + const y = asNumber(src.subtitlePosition.yPercent); + if (y !== undefined) { + resolved.subtitlePosition.yPercent = y; + } + } +} diff --git a/src/config/resolve/immersion-tracking.ts b/src/config/resolve/immersion-tracking.ts new file mode 100644 index 0000000..883a4aa --- /dev/null +++ b/src/config/resolve/immersion-tracking.ts @@ -0,0 +1,173 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyImmersionTrackingConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.immersionTracking)) { + const enabled = asBoolean(src.immersionTracking.enabled); + if (enabled !== undefined) { + resolved.immersionTracking.enabled = enabled; + } else if (src.immersionTracking.enabled !== undefined) { + warn( + 'immersionTracking.enabled', + src.immersionTracking.enabled, + resolved.immersionTracking.enabled, + 'Expected boolean.', + ); + } + + const dbPath = asString(src.immersionTracking.dbPath); + if (dbPath !== undefined) { + resolved.immersionTracking.dbPath = dbPath; + } else if (src.immersionTracking.dbPath !== undefined) { + warn( + 'immersionTracking.dbPath', + src.immersionTracking.dbPath, + resolved.immersionTracking.dbPath, + 'Expected string.', + ); + } + + const batchSize = asNumber(src.immersionTracking.batchSize); + if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) { + resolved.immersionTracking.batchSize = Math.floor(batchSize); + } else if (src.immersionTracking.batchSize !== undefined) { + warn( + 'immersionTracking.batchSize', + src.immersionTracking.batchSize, + resolved.immersionTracking.batchSize, + 'Expected integer between 1 and 10000.', + ); + } + + const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs); + if (flushIntervalMs !== undefined && flushIntervalMs >= 50 && flushIntervalMs <= 60_000) { + resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs); + } else if (src.immersionTracking.flushIntervalMs !== undefined) { + warn( + 'immersionTracking.flushIntervalMs', + src.immersionTracking.flushIntervalMs, + resolved.immersionTracking.flushIntervalMs, + 'Expected integer between 50 and 60000.', + ); + } + + const queueCap = asNumber(src.immersionTracking.queueCap); + if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) { + resolved.immersionTracking.queueCap = Math.floor(queueCap); + } else if (src.immersionTracking.queueCap !== undefined) { + warn( + 'immersionTracking.queueCap', + src.immersionTracking.queueCap, + resolved.immersionTracking.queueCap, + 'Expected integer between 100 and 100000.', + ); + } + + const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes); + if (payloadCapBytes !== undefined && payloadCapBytes >= 64 && payloadCapBytes <= 8192) { + resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes); + } else if (src.immersionTracking.payloadCapBytes !== undefined) { + warn( + 'immersionTracking.payloadCapBytes', + src.immersionTracking.payloadCapBytes, + resolved.immersionTracking.payloadCapBytes, + 'Expected integer between 64 and 8192.', + ); + } + + const maintenanceIntervalMs = asNumber(src.immersionTracking.maintenanceIntervalMs); + if ( + maintenanceIntervalMs !== undefined && + maintenanceIntervalMs >= 60_000 && + maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000 + ) { + resolved.immersionTracking.maintenanceIntervalMs = Math.floor(maintenanceIntervalMs); + } else if (src.immersionTracking.maintenanceIntervalMs !== undefined) { + warn( + 'immersionTracking.maintenanceIntervalMs', + src.immersionTracking.maintenanceIntervalMs, + resolved.immersionTracking.maintenanceIntervalMs, + 'Expected integer between 60000 and 604800000.', + ); + } + + if (isObject(src.immersionTracking.retention)) { + const eventsDays = asNumber(src.immersionTracking.retention.eventsDays); + if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) { + resolved.immersionTracking.retention.eventsDays = Math.floor(eventsDays); + } else if (src.immersionTracking.retention.eventsDays !== undefined) { + warn( + 'immersionTracking.retention.eventsDays', + src.immersionTracking.retention.eventsDays, + resolved.immersionTracking.retention.eventsDays, + 'Expected integer between 1 and 3650.', + ); + } + + const telemetryDays = asNumber(src.immersionTracking.retention.telemetryDays); + if (telemetryDays !== undefined && telemetryDays >= 1 && telemetryDays <= 3650) { + resolved.immersionTracking.retention.telemetryDays = Math.floor(telemetryDays); + } else if (src.immersionTracking.retention.telemetryDays !== undefined) { + warn( + 'immersionTracking.retention.telemetryDays', + src.immersionTracking.retention.telemetryDays, + resolved.immersionTracking.retention.telemetryDays, + 'Expected integer between 1 and 3650.', + ); + } + + const dailyRollupsDays = asNumber(src.immersionTracking.retention.dailyRollupsDays); + if (dailyRollupsDays !== undefined && dailyRollupsDays >= 1 && dailyRollupsDays <= 36500) { + resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(dailyRollupsDays); + } else if (src.immersionTracking.retention.dailyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.dailyRollupsDays', + src.immersionTracking.retention.dailyRollupsDays, + resolved.immersionTracking.retention.dailyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const monthlyRollupsDays = asNumber(src.immersionTracking.retention.monthlyRollupsDays); + if ( + monthlyRollupsDays !== undefined && + monthlyRollupsDays >= 1 && + monthlyRollupsDays <= 36500 + ) { + resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(monthlyRollupsDays); + } else if (src.immersionTracking.retention.monthlyRollupsDays !== undefined) { + warn( + 'immersionTracking.retention.monthlyRollupsDays', + src.immersionTracking.retention.monthlyRollupsDays, + resolved.immersionTracking.retention.monthlyRollupsDays, + 'Expected integer between 1 and 36500.', + ); + } + + const vacuumIntervalDays = asNumber(src.immersionTracking.retention.vacuumIntervalDays); + if ( + vacuumIntervalDays !== undefined && + vacuumIntervalDays >= 1 && + vacuumIntervalDays <= 3650 + ) { + resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(vacuumIntervalDays); + } else if (src.immersionTracking.retention.vacuumIntervalDays !== undefined) { + warn( + 'immersionTracking.retention.vacuumIntervalDays', + src.immersionTracking.retention.vacuumIntervalDays, + resolved.immersionTracking.retention.vacuumIntervalDays, + 'Expected integer between 1 and 3650.', + ); + } + } else if (src.immersionTracking.retention !== undefined) { + warn( + 'immersionTracking.retention', + src.immersionTracking.retention, + resolved.immersionTracking.retention, + 'Expected object.', + ); + } + } +} diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts new file mode 100644 index 0000000..b6051f9 --- /dev/null +++ b/src/config/resolve/integrations.ts @@ -0,0 +1,92 @@ +import { ResolveContext } from './context'; +import { asBoolean, asString, isObject } from './shared'; + +export function applyIntegrationConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.anilist)) { + const enabled = asBoolean(src.anilist.enabled); + if (enabled !== undefined) { + resolved.anilist.enabled = enabled; + } else if (src.anilist.enabled !== undefined) { + warn('anilist.enabled', src.anilist.enabled, resolved.anilist.enabled, 'Expected boolean.'); + } + + const accessToken = asString(src.anilist.accessToken); + if (accessToken !== undefined) { + resolved.anilist.accessToken = accessToken; + } else if (src.anilist.accessToken !== undefined) { + warn( + 'anilist.accessToken', + src.anilist.accessToken, + resolved.anilist.accessToken, + 'Expected string.', + ); + } + } + + if (isObject(src.jellyfin)) { + const enabled = asBoolean(src.jellyfin.enabled); + if (enabled !== undefined) { + resolved.jellyfin.enabled = enabled; + } else if (src.jellyfin.enabled !== undefined) { + warn( + 'jellyfin.enabled', + src.jellyfin.enabled, + resolved.jellyfin.enabled, + 'Expected boolean.', + ); + } + + const stringKeys = [ + 'serverUrl', + 'username', + 'accessToken', + 'userId', + 'deviceId', + 'clientName', + 'clientVersion', + 'defaultLibraryId', + 'iconCacheDir', + 'transcodeVideoCodec', + ] as const; + for (const key of stringKeys) { + const value = asString(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected string.'); + } + } + + const booleanKeys = [ + 'remoteControlEnabled', + 'remoteControlAutoConnect', + 'autoAnnounce', + 'directPlayPreferred', + 'pullPictures', + ] as const; + for (const key of booleanKeys) { + const value = asBoolean(src.jellyfin[key]); + if (value !== undefined) { + resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key]; + } else if (src.jellyfin[key] !== undefined) { + warn(`jellyfin.${key}`, src.jellyfin[key], resolved.jellyfin[key], 'Expected boolean.'); + } + } + + if (Array.isArray(src.jellyfin.directPlayContainers)) { + resolved.jellyfin.directPlayContainers = src.jellyfin.directPlayContainers + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); + } else if (src.jellyfin.directPlayContainers !== undefined) { + warn( + 'jellyfin.directPlayContainers', + src.jellyfin.directPlayContainers, + resolved.jellyfin.directPlayContainers, + 'Expected string array.', + ); + } + } +} diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts new file mode 100644 index 0000000..0c8eb79 --- /dev/null +++ b/src/config/resolve/jellyfin.test.ts @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createResolveContext } from './context'; +import { applyIntegrationConfig } from './integrations'; + +test('jellyfin directPlayContainers are normalized', () => { + const { context } = createResolveContext({ + jellyfin: { + directPlayContainers: [' MKV ', 'mp4', '', ' WebM ', 42 as unknown as string], + }, + }); + + applyIntegrationConfig(context); + + assert.deepEqual(context.resolved.jellyfin.directPlayContainers, ['mkv', 'mp4', 'webm']); +}); diff --git a/src/config/resolve/shared.ts b/src/config/resolve/shared.ts new file mode 100644 index 0000000..2490f91 --- /dev/null +++ b/src/config/resolve/shared.ts @@ -0,0 +1,38 @@ +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function asBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +export function asColor(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const text = value.trim(); + return hexColorPattern.test(text) ? text : undefined; +} + +export function asFrequencyBandedColors( + value: unknown, +): [string, string, string, string, string] | undefined { + if (!Array.isArray(value) || value.length !== 5) { + return undefined; + } + + const colors = value.map((item) => asColor(item)); + if (colors.some((color) => color === undefined)) { + return undefined; + } + + return colors as [string, string, string, string, string]; +} diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts new file mode 100644 index 0000000..81f3fea --- /dev/null +++ b/src/config/resolve/subtitle-domains.ts @@ -0,0 +1,225 @@ +import { ResolvedConfig } from '../../types'; +import { ResolveContext } from './context'; +import { + asBoolean, + asColor, + asFrequencyBandedColors, + asNumber, + asString, + isObject, +} from './shared'; + +export function applySubtitleDomainConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (isObject(src.jimaku)) { + const apiKey = asString(src.jimaku.apiKey); + if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey; + const apiKeyCommand = asString(src.jimaku.apiKeyCommand); + if (apiKeyCommand !== undefined) resolved.jimaku.apiKeyCommand = apiKeyCommand; + const apiBaseUrl = asString(src.jimaku.apiBaseUrl); + if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl; + + const lang = src.jimaku.languagePreference; + if (lang === 'ja' || lang === 'en' || lang === 'none') { + resolved.jimaku.languagePreference = lang; + } else if (lang !== undefined) { + warn( + 'jimaku.languagePreference', + lang, + resolved.jimaku.languagePreference, + 'Expected ja, en, or none.', + ); + } + + const maxEntryResults = asNumber(src.jimaku.maxEntryResults); + if (maxEntryResults !== undefined && maxEntryResults > 0) { + resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults); + } else if (src.jimaku.maxEntryResults !== undefined) { + warn( + 'jimaku.maxEntryResults', + src.jimaku.maxEntryResults, + resolved.jimaku.maxEntryResults, + 'Expected positive number.', + ); + } + } + + if (isObject(src.youtubeSubgen)) { + const mode = src.youtubeSubgen.mode; + if (mode === 'automatic' || mode === 'preprocess' || mode === 'off') { + resolved.youtubeSubgen.mode = mode; + } else if (mode !== undefined) { + warn( + 'youtubeSubgen.mode', + mode, + resolved.youtubeSubgen.mode, + 'Expected automatic, preprocess, or off.', + ); + } + + const whisperBin = asString(src.youtubeSubgen.whisperBin); + if (whisperBin !== undefined) { + resolved.youtubeSubgen.whisperBin = whisperBin; + } else if (src.youtubeSubgen.whisperBin !== undefined) { + warn( + 'youtubeSubgen.whisperBin', + src.youtubeSubgen.whisperBin, + resolved.youtubeSubgen.whisperBin, + 'Expected string.', + ); + } + + const whisperModel = asString(src.youtubeSubgen.whisperModel); + if (whisperModel !== undefined) { + resolved.youtubeSubgen.whisperModel = whisperModel; + } else if (src.youtubeSubgen.whisperModel !== undefined) { + warn( + 'youtubeSubgen.whisperModel', + src.youtubeSubgen.whisperModel, + resolved.youtubeSubgen.whisperModel, + 'Expected string.', + ); + } + + if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) { + resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter( + (item): item is string => typeof item === 'string', + ); + } else if (src.youtubeSubgen.primarySubLanguages !== undefined) { + warn( + 'youtubeSubgen.primarySubLanguages', + src.youtubeSubgen.primarySubLanguages, + resolved.youtubeSubgen.primarySubLanguages, + 'Expected string array.', + ); + } + } + + if (isObject(src.subtitleStyle)) { + const fallbackSubtitleStyleEnableJlpt = resolved.subtitleStyle.enableJlpt; + const fallbackSubtitleStylePreserveLineBreaks = resolved.subtitleStyle.preserveLineBreaks; + resolved.subtitleStyle = { + ...resolved.subtitleStyle, + ...(src.subtitleStyle as ResolvedConfig['subtitleStyle']), + secondary: { + ...resolved.subtitleStyle.secondary, + ...(isObject(src.subtitleStyle.secondary) + ? (src.subtitleStyle.secondary as ResolvedConfig['subtitleStyle']['secondary']) + : {}), + }, + }; + + const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt); + if (enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = enableJlpt; + } else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) { + resolved.subtitleStyle.enableJlpt = fallbackSubtitleStyleEnableJlpt; + warn( + 'subtitleStyle.enableJlpt', + (src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt, + resolved.subtitleStyle.enableJlpt, + 'Expected boolean.', + ); + } + + const preserveLineBreaks = asBoolean( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + ); + if (preserveLineBreaks !== undefined) { + resolved.subtitleStyle.preserveLineBreaks = preserveLineBreaks; + } else if ( + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks !== undefined + ) { + resolved.subtitleStyle.preserveLineBreaks = fallbackSubtitleStylePreserveLineBreaks; + warn( + 'subtitleStyle.preserveLineBreaks', + (src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks, + resolved.subtitleStyle.preserveLineBreaks, + 'Expected boolean.', + ); + } + + const frequencyDictionary = isObject( + (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, + ) + ? ((src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary as Record< + string, + unknown + >) + : {}; + const frequencyEnabled = asBoolean((frequencyDictionary as { enabled?: unknown }).enabled); + if (frequencyEnabled !== undefined) { + resolved.subtitleStyle.frequencyDictionary.enabled = frequencyEnabled; + } else if ((frequencyDictionary as { enabled?: unknown }).enabled !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.enabled', + (frequencyDictionary as { enabled?: unknown }).enabled, + resolved.subtitleStyle.frequencyDictionary.enabled, + 'Expected boolean.', + ); + } + + const sourcePath = asString((frequencyDictionary as { sourcePath?: unknown }).sourcePath); + if (sourcePath !== undefined) { + resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath; + } else if ((frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.sourcePath', + (frequencyDictionary as { sourcePath?: unknown }).sourcePath, + resolved.subtitleStyle.frequencyDictionary.sourcePath, + 'Expected string.', + ); + } + + const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX); + if (topX !== undefined && Number.isInteger(topX) && topX > 0) { + resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX); + } else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.topX', + (frequencyDictionary as { topX?: unknown }).topX, + resolved.subtitleStyle.frequencyDictionary.topX, + 'Expected a positive integer.', + ); + } + + const frequencyMode = frequencyDictionary.mode; + if (frequencyMode === 'single' || frequencyMode === 'banded') { + resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode; + } else if (frequencyMode !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.mode', + frequencyDictionary.mode, + resolved.subtitleStyle.frequencyDictionary.mode, + "Expected 'single' or 'banded'.", + ); + } + + const singleColor = asColor((frequencyDictionary as { singleColor?: unknown }).singleColor); + if (singleColor !== undefined) { + resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor; + } else if ((frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.singleColor', + (frequencyDictionary as { singleColor?: unknown }).singleColor, + resolved.subtitleStyle.frequencyDictionary.singleColor, + 'Expected hex color.', + ); + } + + const bandedColors = asFrequencyBandedColors( + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + ); + if (bandedColors !== undefined) { + resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors; + } else if ((frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined) { + warn( + 'subtitleStyle.frequencyDictionary.bandedColors', + (frequencyDictionary as { bandedColors?: unknown }).bandedColors, + resolved.subtitleStyle.frequencyDictionary.bandedColors, + 'Expected an array of five hex colors.', + ); + } + } +} diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts new file mode 100644 index 0000000..43c7a3d --- /dev/null +++ b/src/config/resolve/subtitle-style.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createResolveContext } from './context'; +import { applySubtitleDomainConfig } from './subtitle-domains'; + +test('subtitleStyle preserveLineBreaks falls back while merge is preserved', () => { + const { context, warnings } = createResolveContext({ + subtitleStyle: { + preserveLineBreaks: 'invalid' as unknown as boolean, + backgroundColor: 'rgb(1, 2, 3, 0.5)', + secondary: { + fontColor: 'yellow', + }, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.preserveLineBreaks, false); + assert.equal(context.resolved.subtitleStyle.backgroundColor, 'rgb(1, 2, 3, 0.5)'); + assert.equal(context.resolved.subtitleStyle.secondary.fontColor, 'yellow'); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.preserveLineBreaks' && + warning.message === 'Expected boolean.', + ), + ); +}); diff --git a/src/config/resolve/top-level.ts b/src/config/resolve/top-level.ts new file mode 100644 index 0000000..1f8f87f --- /dev/null +++ b/src/config/resolve/top-level.ts @@ -0,0 +1,28 @@ +import { ResolveContext } from './context'; +import { asBoolean } from './shared'; + +export function applyTopLevelConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + 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 (asBoolean(src.auto_start_overlay) !== undefined) { + resolved.auto_start_overlay = src.auto_start_overlay as boolean; + } + + if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) { + resolved.bind_visible_overlay_to_mpv_sub_visibility = + src.bind_visible_overlay_to_mpv_sub_visibility as boolean; + } else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) { + warn( + 'bind_visible_overlay_to_mpv_sub_visibility', + src.bind_visible_overlay_to_mpv_sub_visibility, + resolved.bind_visible_overlay_to_mpv_sub_visibility, + 'Expected boolean.', + ); + } +}