feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

1112
src/config/config.test.ts Normal file

File diff suppressed because it is too large Load Diff

101
src/config/definitions.ts Normal file
View File

@@ -0,0 +1,101 @@
import { RawConfig, ResolvedConfig } from '../types';
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle';
import { buildCoreConfigOptionRegistry } from './definitions/options-core';
import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
export { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from './definitions/shared';
export type {
ConfigOptionRegistryEntry,
ConfigTemplateSection,
ConfigValueKind,
RuntimeOptionRegistryEntry,
} from './definitions/shared';
const {
subtitlePosition,
keybindings,
websocket,
logging,
texthooker,
shortcuts,
secondarySub,
subsync,
auto_start_overlay,
bind_visible_overlay_to_mpv_sub_visibility,
invisibleOverlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitlePosition,
keybindings,
websocket,
logging,
texthooker,
ankiConnect,
shortcuts,
secondarySub,
subsync,
subtitleStyle,
auto_start_overlay,
bind_visible_overlay_to_mpv_sub_visibility,
jimaku,
anilist,
jellyfin,
discordPresence,
youtubeSubgen,
invisibleOverlay,
immersionTracking,
};
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
export const RUNTIME_OPTION_REGISTRY = buildRuntimeOptionRegistry(DEFAULT_CONFIG);
export const CONFIG_OPTION_REGISTRY = [
...buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
];
export { CONFIG_TEMPLATE_SECTIONS };
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
}
export function deepMergeRawConfig(base: RawConfig, patch: RawConfig): RawConfig {
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
const patchObject = patch as Record<string, unknown>;
const mergeInto = (target: Record<string, unknown>, source: Record<string, unknown>): void => {
for (const [key, value] of Object.entries(source)) {
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof target[key] === 'object' &&
target[key] !== null &&
!Array.isArray(target[key])
) {
mergeInto(target[key] as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = value;
}
}
};
mergeInto(clone, patchObject);
return clone as RawConfig;
}

View File

@@ -0,0 +1,61 @@
import { ResolvedConfig } from '../../types';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
| 'logging'
| 'texthooker'
| 'shortcuts'
| 'secondarySub'
| 'subsync'
| 'auto_start_overlay'
| 'bind_visible_overlay_to_mpv_sub_visibility'
| 'invisibleOverlay'
> = {
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
enabled: 'auto',
port: 6677,
},
logging: {
level: 'info',
},
texthooker: {
openBrowser: true,
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
toggleInvisibleOverlayGlobal: 'Alt+Shift+I',
copySubtitle: 'CommandOrControl+C',
copySubtitleMultiple: 'CommandOrControl+Shift+C',
updateLastCardFromClipboard: 'CommandOrControl+V',
triggerFieldGrouping: 'CommandOrControl+G',
triggerSubsync: 'Ctrl+Alt+S',
mineSentence: 'CommandOrControl+S',
mineSentenceMultiple: 'CommandOrControl+Shift+S',
multiCopyTimeoutMs: 3000,
toggleSecondarySub: 'CommandOrControl+Shift+V',
markAudioCard: 'CommandOrControl+Shift+A',
openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J',
},
secondarySub: {
secondarySubLanguages: [],
autoLoadSecondarySub: false,
defaultMode: 'hover',
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
},
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default',
},
};

View File

@@ -0,0 +1,20 @@
import { ResolvedConfig } from '../../types';
export const IMMERSION_DEFAULT_CONFIG: Pick<ResolvedConfig, 'immersionTracking'> = {
immersionTracking: {
enabled: true,
dbPath: '',
batchSize: 25,
flushIntervalMs: 500,
queueCap: 1000,
payloadCapBytes: 256,
maintenanceIntervalMs: 24 * 60 * 60 * 1000,
retention: {
eventsDays: 7,
telemetryDays: 30,
dailyRollupsDays: 365,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
},
},
};

View File

@@ -0,0 +1,113 @@
import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
url: 'http://127.0.0.1:8765',
pollingRate: 3000,
tags: ['SubMiner'],
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
translation: 'SelectionText',
},
ai: {
enabled: false,
alwaysUseAiTranslation: false,
apiKey: '',
model: 'openai/gpt-4o-mini',
baseUrl: 'https://openrouter.ai/api',
targetLanguage: 'English',
systemPrompt:
'You are a translation engine. Return only the translated text with no explanations.',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'static',
imageFormat: 'jpg',
imageQuality: 92,
imageMaxWidth: undefined,
imageMaxHeight: undefined,
animatedFps: 10,
animatedMaxWidth: 640,
animatedMaxHeight: undefined,
animatedCrf: 35,
audioPadding: 0.5,
fallbackDuration: 3.0,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: true,
overwriteImage: true,
mediaInsertMode: 'append',
highlightWord: true,
notificationType: 'osd',
autoUpdateNewCards: true,
},
nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: 'headword',
decks: [],
minSentenceWords: 3,
nPlusOne: '#c6a0f6',
knownWord: '#a6da95',
},
metadata: {
pattern: '[SubMiner] %f (%t)',
},
isLapis: {
enabled: false,
sentenceCardModel: 'Japanese sentences',
},
isKiku: {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
languagePreference: 'ja',
maxEntryResults: 10,
},
anilist: {
enabled: false,
accessToken: '',
},
jellyfin: {
enabled: false,
serverUrl: '',
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '',
remoteControlEnabled: true,
remoteControlAutoConnect: true,
autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true,
directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'],
transcodeVideoCodec: 'h264',
},
discordPresence: {
enabled: false,
updateIntervalMs: 3_000,
debounceMs: 750,
},
youtubeSubgen: {
mode: 'automatic',
whisperBin: '',
whisperModel: '',
primarySubLanguages: ['ja', 'jpn'],
},
};

View File

@@ -0,0 +1,42 @@
import { ResolvedConfig } from '../../types';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: {
enableJlpt: false,
preserveLineBreaks: false,
hoverTokenColor: '#c6a0f6',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
fontSize: 35,
fontColor: '#cad3f5',
fontWeight: 'normal',
fontStyle: 'normal',
backgroundColor: 'rgb(30, 32, 48, 0.88)',
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
frequencyDictionary: {
enabled: false,
sourcePath: '',
topX: 1000,
mode: 'single',
singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
},
secondary: {
fontSize: 24,
fontColor: '#ffffff',
backgroundColor: 'transparent',
fontWeight: 'normal',
fontStyle: 'normal',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
},
},
};

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
RUNTIME_OPTION_REGISTRY,
} from '../definitions';
import { buildCoreConfigOptionRegistry } from './options-core';
import { buildImmersionConfigOptionRegistry } from './options-immersion';
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
test('config option registry includes critical paths and has unique entries', () => {
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
for (const requiredPath of [
'logging.level',
'subtitleStyle.enableJlpt',
'ankiConnect.enabled',
'immersionTracking.enabled',
]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
}
assert.equal(new Set(paths).size, paths.length);
});
test('config template sections include expected domains and unique keys', () => {
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
const requiredKeys: (typeof keys)[number][] = [
'websocket',
'subtitleStyle',
'ankiConnect',
'immersionTracking',
];
for (const requiredKey of requiredKeys) {
assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
}
assert.equal(new Set(keys).size, keys.length);
});
test('domain registry builders each contribute entries to composed registry', () => {
const domainEntries = [
buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
];
const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
for (const entries of domainEntries) {
assert.ok(entries.length > 0);
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
}
});

View File

@@ -0,0 +1,49 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'logging.level',
kind: 'enum',
enumValues: ['debug', 'info', 'warn', 'error'],
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'websocket.enabled',
kind: 'enum',
enumValues: ['auto', 'true', 'false'],
defaultValue: defaultConfig.websocket.enabled,
description: 'Built-in subtitle websocket server mode.',
},
{
path: 'websocket.port',
kind: 'number',
defaultValue: defaultConfig.websocket.port,
description: 'Built-in subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
description: 'Timeout for multi-copy/mine modes.',
},
{
path: 'bind_visible_overlay_to_mpv_sub_visibility',
kind: 'boolean',
defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
description:
'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).',
},
];
}

View File

@@ -0,0 +1,82 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildImmersionConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'immersionTracking.enabled',
kind: 'boolean',
defaultValue: defaultConfig.immersionTracking.enabled,
description: 'Enable immersion tracking for mined subtitle metadata.',
},
{
path: 'immersionTracking.dbPath',
kind: 'string',
defaultValue: defaultConfig.immersionTracking.dbPath,
description:
'Optional SQLite database path for immersion tracking. Empty value uses the default app data path.',
},
{
path: 'immersionTracking.batchSize',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.batchSize,
description: 'Buffered telemetry/event writes per SQLite transaction.',
},
{
path: 'immersionTracking.flushIntervalMs',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.flushIntervalMs,
description: 'Max delay before queue flush in milliseconds.',
},
{
path: 'immersionTracking.queueCap',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.queueCap,
description: 'In-memory write queue cap before overflow policy applies.',
},
{
path: 'immersionTracking.payloadCapBytes',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.payloadCapBytes,
description: 'Max JSON payload size per event before truncation.',
},
{
path: 'immersionTracking.maintenanceIntervalMs',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.maintenanceIntervalMs,
description: 'Maintenance cadence (prune + rollup + vacuum checks).',
},
{
path: 'immersionTracking.retention.eventsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.eventsDays,
description: 'Raw event retention window in days.',
},
{
path: 'immersionTracking.retention.telemetryDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.telemetryDays,
description: 'Telemetry retention window in days.',
},
{
path: 'immersionTracking.retention.dailyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.dailyRollupsDays,
description: 'Daily rollup retention window in days.',
},
{
path: 'immersionTracking.retention.monthlyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.monthlyRollupsDays,
description: 'Monthly rollup retention window in days.',
},
{
path: 'immersionTracking.retention.vacuumIntervalDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.vacuumIntervalDays,
description: 'Minimum days between VACUUM runs.',
},
];
}

View File

@@ -0,0 +1,235 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
export function buildIntegrationConfigOptionRegistry(
defaultConfig: ResolvedConfig,
runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
): ConfigOptionRegistryEntry[] {
return [
{
path: 'ankiConnect.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.enabled,
description: 'Enable AnkiConnect integration.',
},
{
path: 'ankiConnect.pollingRate',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.pollingRate,
description: 'Polling interval in milliseconds.',
},
{
path: 'ankiConnect.tags',
kind: 'array',
defaultValue: defaultConfig.ankiConnect.tags,
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
description: 'Automatically update newly added cards.',
runtime: runtimeOptionRegistry[0],
},
{
path: 'ankiConnect.nPlusOne.matchMode',
kind: 'enum',
enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
description: 'Known-word matching strategy for N+1 highlighting.',
},
{
path: 'ankiConnect.nPlusOne.highlightEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.',
},
{
path: 'ankiConnect.nPlusOne.refreshMinutes',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes,
description: 'Minutes between known-word cache refreshes.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.minSentenceWords,
description: 'Minimum sentence word count required for N+1 targeting (default: 3).',
},
{
path: 'ankiConnect.nPlusOne.decks',
kind: 'array',
defaultValue: defaultConfig.ankiConnect.nPlusOne.decks,
description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.',
},
{
path: 'ankiConnect.nPlusOne.nPlusOne',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
description: 'Color used for the single N+1 target token highlight.',
},
{
path: 'ankiConnect.nPlusOne.knownWord',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord,
description: 'Color used for legacy known-word highlights.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
kind: 'enum',
enumValues: ['auto', 'manual', 'disabled'],
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
description: 'Kiku duplicate-card field grouping mode.',
runtime: runtimeOptionRegistry[1],
},
{
path: 'jimaku.languagePreference',
kind: 'enum',
enumValues: ['ja', 'en', 'none'],
defaultValue: defaultConfig.jimaku.languagePreference,
description: 'Preferred language used in Jimaku search.',
},
{
path: 'jimaku.maxEntryResults',
kind: 'number',
defaultValue: defaultConfig.jimaku.maxEntryResults,
description: 'Maximum Jimaku search results returned.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.enabled,
description: 'Enable AniList post-watch progress updates.',
},
{
path: 'anilist.accessToken',
kind: 'string',
defaultValue: defaultConfig.anilist.accessToken,
description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.enabled,
description: 'Enable optional Jellyfin integration and CLI control commands.',
},
{
path: 'jellyfin.serverUrl',
kind: 'string',
defaultValue: defaultConfig.jellyfin.serverUrl,
description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
},
{
path: 'jellyfin.username',
kind: 'string',
defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.',
},
{
path: 'jellyfin.defaultLibraryId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.defaultLibraryId,
description: 'Optional default Jellyfin library ID for item listing.',
},
{
path: 'jellyfin.remoteControlEnabled',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.remoteControlEnabled,
description: 'Enable Jellyfin remote cast control mode.',
},
{
path: 'jellyfin.remoteControlAutoConnect',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.remoteControlAutoConnect,
description: 'Auto-connect to the configured remote control target.',
},
{
path: 'jellyfin.autoAnnounce',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.autoAnnounce,
description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
},
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{
path: 'jellyfin.pullPictures',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.pullPictures,
description: 'Enable Jellyfin poster/icon fetching for launcher menus.',
},
{
path: 'jellyfin.iconCacheDir',
kind: 'string',
defaultValue: defaultConfig.jellyfin.iconCacheDir,
description: 'Directory used by launcher for cached Jellyfin poster icons.',
},
{
path: 'jellyfin.directPlayPreferred',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.directPlayPreferred,
description: 'Try direct play before server-managed transcoding when possible.',
},
{
path: 'jellyfin.directPlayContainers',
kind: 'array',
defaultValue: defaultConfig.jellyfin.directPlayContainers,
description: 'Container allowlist for direct play decisions.',
},
{
path: 'jellyfin.transcodeVideoCodec',
kind: 'string',
defaultValue: defaultConfig.jellyfin.transcodeVideoCodec,
description: 'Preferred transcode video codec when direct play is unavailable.',
},
{
path: 'discordPresence.enabled',
kind: 'boolean',
defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.',
},
{
path: 'discordPresence.updateIntervalMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.updateIntervalMs,
description: 'Minimum interval between presence payload updates.',
},
{
path: 'discordPresence.debounceMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.debounceMs,
description: 'Debounce delay used to collapse bursty presence updates.',
},
{
path: 'youtubeSubgen.mode',
kind: 'enum',
enumValues: ['automatic', 'preprocess', 'off'],
defaultValue: defaultConfig.youtubeSubgen.mode,
description: 'YouTube subtitle generation mode for the launcher script.',
},
{
path: 'youtubeSubgen.whisperBin',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
description: 'Path to whisper.cpp CLI used as fallback transcription engine.',
},
{
path: 'youtubeSubgen.whisperModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Path to whisper model used for fallback transcription.',
},
{
path: 'youtubeSubgen.primarySubLanguages',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.primarySubLanguages.join(','),
description: 'Comma-separated primary subtitle language priority used by the launcher.',
},
];
}

View File

@@ -0,0 +1,72 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'subtitleStyle.enableJlpt',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.enableJlpt,
description:
'Enable JLPT vocabulary level underlines. ' +
'When disabled, JLPT tagging lookup and underlines are skipped.',
},
{
path: 'subtitleStyle.preserveLineBreaks',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.preserveLineBreaks,
description:
'Preserve line breaks in visible overlay subtitle rendering. ' +
'When false, line breaks are flattened to spaces for a single-line flow.',
},
{
path: 'subtitleStyle.hoverTokenColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
description: 'Hex color used for hovered subtitle token highlight in mpv.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
description: 'Enable frequency-dictionary-based highlighting based on token rank.',
},
{
path: 'subtitleStyle.frequencyDictionary.sourcePath',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.sourcePath,
description:
'Optional absolute path to a frequency dictionary directory.' +
' If empty, built-in discovery search paths are used.',
},
{
path: 'subtitleStyle.frequencyDictionary.topX',
kind: 'number',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX,
description: 'Only color tokens with frequency rank <= topX (default: 1000).',
},
{
path: 'subtitleStyle.frequencyDictionary.mode',
kind: 'enum',
enumValues: ['single', 'banded'],
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.mode,
description:
'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
},
{
path: 'subtitleStyle.frequencyDictionary.singleColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.singleColor,
description: 'Color used when frequencyDictionary.mode is `single`.',
},
{
path: 'subtitleStyle.frequencyDictionary.bandedColors',
kind: 'array',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.bandedColors,
description:
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
},
];
}

View File

@@ -0,0 +1,56 @@
import { ResolvedConfig } from '../../types';
import { RuntimeOptionRegistryEntry } from './shared';
export function buildRuntimeOptionRegistry(
defaultConfig: ResolvedConfig,
): RuntimeOptionRegistryEntry[] {
return [
{
id: 'anki.autoUpdateNewCards',
path: 'ankiConnect.behavior.autoUpdateNewCards',
label: 'Auto Update New Cards',
scope: 'ankiConnect',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: 'anki.nPlusOneMatchMode',
path: 'ankiConnect.nPlusOne.matchMode',
label: 'N+1 Match Mode',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
nPlusOne: {
matchMode: value === 'headword' || value === 'surface' ? value : 'headword',
},
}),
},
{
id: 'anki.kikuFieldGrouping',
path: 'ankiConnect.isKiku.fieldGrouping',
label: 'Kiku Field Grouping',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['auto', 'manual', 'disabled'],
defaultValue: 'disabled',
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
isKiku: {
fieldGrouping:
value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'disabled',
},
}),
},
];
}

View File

@@ -0,0 +1,61 @@
import {
AnkiConnectConfig,
ResolvedConfig,
RuntimeOptionId,
RuntimeOptionScope,
RuntimeOptionValue,
RuntimeOptionValueType,
} from '../../types';
export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object';
export interface RuntimeOptionRegistryEntry {
id: RuntimeOptionId;
path: string;
label: string;
scope: RuntimeOptionScope;
valueType: RuntimeOptionValueType;
allowedValues: RuntimeOptionValue[];
defaultValue: RuntimeOptionValue;
requiresRestart: boolean;
formatValueForOsd: (value: RuntimeOptionValue) => string;
toAnkiPatch: (value: RuntimeOptionValue) => Partial<AnkiConnectConfig>;
}
export interface ConfigOptionRegistryEntry {
path: string;
kind: ConfigValueKind;
defaultValue: unknown;
description: string;
enumValues?: readonly string[];
runtime?: RuntimeOptionRegistryEntry;
}
export interface ConfigTemplateSection {
title: string;
description: string[];
key: keyof ResolvedConfig;
notes?: string[];
}
export const SPECIAL_COMMANDS = {
SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
{ key: 'Space', command: ['cycle', 'pause'] },
{ key: 'ArrowRight', command: ['seek', 5] },
{ key: 'ArrowLeft', command: ['seek', -5] },
{ key: 'ArrowUp', command: ['seek', 60] },
{ key: 'ArrowDown', command: ['seek', -60] },
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
{ key: 'Shift+KeyL', command: ['sub-seek', 1] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] },
{ key: 'Ctrl+KeyW', command: ['quit'] },
];

View File

@@ -0,0 +1,154 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Overlay Auto-Start',
description: [
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
],
key: 'auto_start_overlay',
},
{
title: 'Visible Overlay Subtitle Binding',
description: [
'Control whether visible overlay toggles also toggle MPV subtitle visibility.',
'When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.',
],
key: 'bind_visible_overlay_to_mpv_sub_visibility',
},
{
title: 'Texthooker Server',
description: ['Control whether browser opens automatically for texthooker.'],
key: 'texthooker',
},
{
title: 'WebSocket Server',
description: [
'Built-in WebSocket server broadcasts subtitle text to connected clients.',
'Auto mode disables built-in server if mpv_websocket is detected.',
],
key: 'websocket',
},
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
key: 'logging',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts',
},
{
title: 'Invisible Overlay',
description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
notes: [
'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.',
'This edit-mode shortcut is fixed and is not currently configurable.',
],
key: 'invisibleOverlay',
},
{
title: 'Keybindings (MPV Commands)',
description: [
'Extra keybindings that are merged with built-in defaults.',
'Set command to null to disable a default keybinding.',
],
notes: [
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
],
key: 'keybindings',
},
{
title: 'Secondary Subtitles',
description: [
'Dual subtitle track options.',
'Used by subminer YouTube subtitle generation as secondary language preferences.',
],
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
key: 'secondarySub',
},
{
title: 'Auto Subtitle Sync',
description: ['Subsync engine and executable paths.'],
key: 'subsync',
},
{
title: 'Subtitle Position',
description: ['Initial vertical subtitle position from the bottom.'],
key: 'subtitlePosition',
},
];
const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Subtitle Appearance',
description: ['Primary and secondary subtitle styling.'],
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle',
},
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect',
},
{
title: 'Jimaku',
description: ['Jimaku API configuration and defaults.'],
key: 'jimaku',
},
{
title: 'YouTube Subtitle Generation',
description: ['Defaults for subminer YouTube subtitle extraction/transcription mode.'],
key: 'youtubeSubgen',
},
{
title: 'Anilist',
description: ['Anilist API credentials and update behavior.'],
key: 'anilist',
},
{
title: 'Jellyfin',
description: [
'Optional Jellyfin integration for auth, browsing, and playback launch.',
'Access token is stored in local encrypted token storage after login/setup.',
'jellyfin.accessToken remains an optional explicit override in config.',
],
key: 'jellyfin',
},
{
title: 'Discord Rich Presence',
description: [
'Optional Discord Rich Presence activity card updates for current playback/study session.',
'Uses official SubMiner Discord app assets for polished card visuals.',
],
key: 'discordPresence',
},
];
const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Immersion Tracking',
description: [
'Enable/disable immersion tracking.',
'Set dbPath to override the default sqlite database location.',
'Policy tuning is available for queue, flush, and retention values.',
],
key: 'immersionTracking',
},
];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
...CORE_TEMPLATE_SECTIONS,
...SUBTITLE_TEMPLATE_SECTIONS,
...INTEGRATION_TEMPLATE_SECTIONS,
...IMMERSION_TEMPLATE_SECTIONS,
];

3
src/config/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './definitions';
export * from './service';
export * from './template';

65
src/config/load.ts Normal file
View File

@@ -0,0 +1,65 @@
import * as fs from 'fs';
import { RawConfig } from '../types';
import { parseConfigContent } from './parse';
export interface ConfigPaths {
configDir: string;
configFileJsonc: string;
configFileJson: string;
}
export interface LoadResult {
config: RawConfig;
path: string;
}
export type StrictLoadResult =
| (LoadResult & { ok: true })
| {
ok: false;
error: string;
path: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function resolveExistingConfigPath(paths: ConfigPaths): string {
if (fs.existsSync(paths.configFileJsonc)) {
return paths.configFileJsonc;
}
if (fs.existsSync(paths.configFileJson)) {
return paths.configFileJson;
}
return paths.configFileJsonc;
}
export function loadRawConfigStrict(paths: ConfigPaths): StrictLoadResult {
const configPath = resolveExistingConfigPath(paths);
if (!fs.existsSync(configPath)) {
return { ok: true, config: {}, path: configPath };
}
try {
const data = fs.readFileSync(configPath, 'utf-8');
const parsed = parseConfigContent(configPath, data);
return {
ok: true,
config: isObject(parsed) ? (parsed as RawConfig) : {},
path: configPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown parse error';
return { ok: false, error: message, path: configPath };
}
}
export function loadRawConfig(paths: ConfigPaths): LoadResult {
const strictResult = loadRawConfigStrict(paths);
if (strictResult.ok) {
return strictResult;
}
return { config: {}, path: strictResult.path };
}

17
src/config/parse.ts Normal file
View File

@@ -0,0 +1,17 @@
import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
export function parseConfigContent(configPath: string, data: string): unknown {
if (!configPath.endsWith('.jsonc')) {
return JSON.parse(data);
}
const errors: ParseError[] = [];
const result = parseJsonc(data, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return result;
}

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveConfigBaseDirs, resolveConfigDir, resolveConfigFilePath } from './path-resolution';
function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
const normalized = new Set(paths.map((entry) => path.normalize(entry)));
return (candidate: string): boolean => normalized.has(path.normalize(candidate));
}
test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
const homeDir = '/home/tester';
const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir);
assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]);
});
test('resolveConfigDir prefers xdg SubMiner config when present', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const configDir = path.join(xdgConfigHome, 'SubMiner');
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigDir ignores lowercase subminer candidate', () => {
const homeDir = '/home/tester';
const lowercaseConfigDir = path.join(homeDir, '.config', 'subminer');
const existsSync = existsSyncFrom([path.join(lowercaseConfigDir, 'config.json')]);
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
existsSync,
});
assert.equal(resolved, '/tmp/missing-xdg/SubMiner');
});
test('resolveConfigDir falls back to existing directory when file is missing', () => {
const homeDir = '/home/tester';
const configDir = path.join(homeDir, '.config', 'SubMiner');
const existsSync = existsSyncFrom([configDir]);
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigFilePath prefers jsonc before json', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
path.join(xdgConfigHome, 'SubMiner', 'config.json'),
]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});
test('resolveConfigFilePath keeps legacy fallback output path', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});

View File

@@ -0,0 +1,76 @@
import path from 'node:path';
type ExistsSync = (candidate: string) => boolean;
type ConfigPathOptions = {
xdgConfigHome?: string;
homeDir: string;
existsSync: ExistsSync;
appNames?: readonly string[];
defaultAppName?: string;
};
const DEFAULT_APP_NAMES = ['SubMiner'] as const;
const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
export function resolveConfigBaseDirs(
xdgConfigHome: string | undefined,
homeDir: string,
): string[] {
const fallbackBaseDir = path.join(homeDir, '.config');
const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
}
function getAppNames(options: ConfigPathOptions): readonly string[] {
return options.appNames ?? DEFAULT_APP_NAMES;
}
function getDefaultAppName(options: ConfigPathOptions): string {
return options.defaultAppName ?? DEFAULT_APP_NAMES[0];
}
export function resolveConfigDir(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
for (const fileName of DEFAULT_FILE_NAMES) {
if (options.existsSync(path.join(dir, fileName))) {
return dir;
}
}
}
}
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
if (options.existsSync(dir)) {
return dir;
}
}
}
return path.join(baseDirs[0]!, getDefaultAppName(options));
}
export function resolveConfigFilePath(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
for (const fileName of DEFAULT_FILE_NAMES) {
const candidate = path.join(baseDir, appName, fileName);
if (options.existsSync(candidate)) {
return candidate;
}
}
}
}
return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!);
}

33
src/config/resolve.ts Normal file
View File

@@ -0,0 +1,33 @@
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { applyAnkiConnectResolution } from './resolve/anki-connect';
import { createResolveContext } from './resolve/context';
import { applyCoreDomainConfig } from './resolve/core-domains';
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
import { applyIntegrationConfig } from './resolve/integrations';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applyTopLevelConfig } from './resolve/top-level';
const APPLY_RESOLVE_STEPS = [
applyTopLevelConfig,
applyCoreDomainConfig,
applySubtitleDomainConfig,
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyAnkiConnectResolution,
] as const;
export function resolveConfig(raw: RawConfig): {
resolved: ResolvedConfig;
warnings: ConfigValidationWarning[];
} {
const { context, warnings } = createResolveContext(raw);
for (const applyStep of APPLY_RESOLVE_STEPS) {
applyStep(context);
}
return {
resolved: context.resolved,
warnings,
};
}

View File

@@ -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<typeof createWarningCollector>['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'));
});

View File

@@ -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<string, unknown>) : {};
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
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<string, unknown>;
const hasOwn = (obj: Record<string, unknown>, 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 = <T>(
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<string, unknown>) : {};
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;
}
}

View File

@@ -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<string, unknown>;
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,
};
}

View File

@@ -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;
}
}
}

View File

@@ -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.',
);
}
}
}

View File

@@ -0,0 +1,128 @@
import { ResolveContext } from './context';
import { asBoolean, asNumber, 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',
'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.',
);
}
}
if (isObject(src.discordPresence)) {
const enabled = asBoolean(src.discordPresence.enabled);
if (enabled !== undefined) {
resolved.discordPresence.enabled = enabled;
} else if (src.discordPresence.enabled !== undefined) {
warn(
'discordPresence.enabled',
src.discordPresence.enabled,
resolved.discordPresence.enabled,
'Expected boolean.',
);
}
const updateIntervalMs = asNumber(src.discordPresence.updateIntervalMs);
if (updateIntervalMs !== undefined) {
resolved.discordPresence.updateIntervalMs = Math.max(1_000, Math.floor(updateIntervalMs));
} else if (src.discordPresence.updateIntervalMs !== undefined) {
warn(
'discordPresence.updateIntervalMs',
src.discordPresence.updateIntervalMs,
resolved.discordPresence.updateIntervalMs,
'Expected number.',
);
}
const debounceMs = asNumber(src.discordPresence.debounceMs);
if (debounceMs !== undefined) {
resolved.discordPresence.debounceMs = Math.max(0, Math.floor(debounceMs));
} else if (src.discordPresence.debounceMs !== undefined) {
warn(
'discordPresence.debounceMs',
src.discordPresence.debounceMs,
resolved.discordPresence.debounceMs,
'Expected number.',
);
}
}
}

View File

@@ -0,0 +1,64 @@
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']);
});
test('jellyfin legacy auth keys are ignored by resolver', () => {
const { context } = createResolveContext({
jellyfin: { accessToken: 'legacy-token', userId: 'legacy-user' } as unknown as never,
});
applyIntegrationConfig(context);
assert.equal('accessToken' in (context.resolved.jellyfin as Record<string, unknown>), false);
assert.equal('userId' in (context.resolved.jellyfin as Record<string, unknown>), false);
});
test('discordPresence fields are parsed and clamped', () => {
const { context } = createResolveContext({
discordPresence: {
enabled: true,
updateIntervalMs: 500,
debounceMs: -100,
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, true);
assert.equal(context.resolved.discordPresence.updateIntervalMs, 1000);
assert.equal(context.resolved.discordPresence.debounceMs, 0);
});
test('discordPresence invalid values warn and keep defaults', () => {
const { context, warnings } = createResolveContext({
discordPresence: {
enabled: 'true' as never,
updateIntervalMs: 'fast' as never,
debounceMs: null as never,
},
});
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, false);
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
assert.equal(context.resolved.discordPresence.debounceMs, 750);
const warnedPaths = warnings.map((warning) => warning.path);
assert.ok(warnedPaths.includes('discordPresence.enabled'));
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
});

View File

@@ -0,0 +1,38 @@
export function isObject(value: unknown): value is Record<string, unknown> {
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];
}

View File

@@ -0,0 +1,239 @@
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;
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
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 hoverTokenColor = asColor((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor);
if (hoverTokenColor !== undefined) {
resolved.subtitleStyle.hoverTokenColor = hoverTokenColor;
} else if ((src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor !== undefined) {
resolved.subtitleStyle.hoverTokenColor = fallbackSubtitleStyleHoverTokenColor;
warn(
'subtitleStyle.hoverTokenColor',
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
resolved.subtitleStyle.hoverTokenColor,
'Expected hex color.',
);
}
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.',
);
}
}
}

View File

@@ -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.',
),
);
});

View File

@@ -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.',
);
}
}

116
src/config/service.ts Normal file
View File

@@ -0,0 +1,116 @@
import * as fs from 'fs';
import * as path from 'path';
import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';
export type ReloadConfigStrictResult =
| {
ok: true;
config: ResolvedConfig;
warnings: ConfigValidationWarning[];
path: string;
}
| {
ok: false;
error: string;
path: string;
};
export class ConfigStartupParseError extends Error {
readonly path: string;
readonly parseError: string;
constructor(configPath: string, parseError: string) {
super(
`Failed to parse startup config at ${configPath}: ${parseError}. Fix the config file and restart SubMiner.`,
);
this.name = 'ConfigStartupParseError';
this.path = configPath;
this.parseError = parseError;
}
}
export class ConfigService {
private readonly configPaths: ConfigPaths;
private rawConfig: RawConfig = {};
private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
private warnings: ConfigValidationWarning[] = [];
private configPathInUse!: string;
constructor(configDir: string) {
this.configPaths = {
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
};
const loadResult = loadRawConfigStrict(this.configPaths);
if (!loadResult.ok) {
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
}
this.applyResolvedConfig(loadResult.config, loadResult.path);
}
getConfigPath(): string {
return this.configPathInUse;
}
getConfig(): ResolvedConfig {
return deepCloneConfig(this.resolvedConfig);
}
getRawConfig(): RawConfig {
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
}
getWarnings(): ConfigValidationWarning[] {
return [...this.warnings];
}
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = loadRawConfig(this.configPaths);
return this.applyResolvedConfig(config, configPath);
}
reloadConfigStrict(): ReloadConfigStrictResult {
const loadResult = loadRawConfigStrict(this.configPaths);
if (!loadResult.ok) {
return loadResult;
}
const { config, path: configPath } = loadResult;
const resolvedConfig = this.applyResolvedConfig(config, configPath);
return {
ok: true,
config: resolvedConfig,
warnings: this.getWarnings(),
path: configPath,
};
}
saveRawConfig(config: RawConfig): void {
if (!fs.existsSync(this.configPaths.configDir)) {
fs.mkdirSync(this.configPaths.configDir, { recursive: true });
}
const targetPath = this.configPathInUse.endsWith('.json')
? this.configPathInUse
: this.configPaths.configFileJsonc;
fs.writeFileSync(targetPath, JSON.stringify(config, null, 2));
this.applyResolvedConfig(config, targetPath);
}
patchRawConfig(patch: RawConfig): void {
const merged = deepMergeRawConfig(this.getRawConfig(), patch);
this.saveRawConfig(merged);
}
private applyResolvedConfig(config: RawConfig, configPath: string): ResolvedConfig {
this.rawConfig = config;
this.configPathInUse = configPath;
const { resolved, warnings } = resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
return this.getConfig();
}
}

135
src/config/template.ts Normal file
View File

@@ -0,0 +1,135 @@
import { ResolvedConfig } from '../types';
import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
deepCloneConfig,
} from './definitions';
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
);
function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
}
function humanizeKey(key: string): string {
const spaced = key
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function buildInlineOptionComment(path: string, value: unknown): string {
const registryEntry = OPTION_REGISTRY_BY_PATH.get(path);
const baseDescription = registryEntry?.description ?? TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY.get(path);
const description =
baseDescription && baseDescription.trim().length > 0
? normalizeCommentText(baseDescription)
: `${humanizeKey(path.split('.').at(-1) ?? path)} setting.`;
if (registryEntry?.enumValues?.length) {
return `${description} Values: ${registryEntry.enumValues.join(' | ')}`;
}
if (typeof value === 'boolean') {
return `${description} Values: true | false`;
}
return description;
}
function renderValue(value: unknown, indent = 0, path = ''): string {
const pad = ' '.repeat(indent);
const nextPad = ' '.repeat(indent + 2);
if (value === null) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2, `${path}[]`)}`);
return `\n${items.join(',\n')}\n${pad}`.replace(/^/, '[').concat(']');
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>).filter(
([, child]) => child !== undefined,
);
if (entries.length === 0) return '{}';
const lines = entries.map(([key, child], index) => {
const isLast = index === entries.length - 1;
const trailingComma = isLast ? '' : ',';
const childPath = path ? `${path}.${key}` : key;
const renderedChild = renderValue(child, indent + 2, childPath);
const comment = buildInlineOptionComment(childPath, child);
if (renderedChild.startsWith('\n')) {
return `${nextPad}${JSON.stringify(key)}: /* ${comment} */ ${renderedChild}${trailingComma}`;
}
return `${nextPad}${JSON.stringify(key)}: ${renderedChild}${trailingComma} // ${comment}`;
});
return `\n${lines.join('\n')}\n${pad}`.replace(/^/, '{').concat('}');
}
return 'null';
}
function renderSection(
key: keyof ResolvedConfig,
value: unknown,
isLast: boolean,
comments: string[],
): string {
const lines: string[] = [];
lines.push(' // ==========================================');
for (const comment of comments) {
lines.push(` // ${comment}`);
}
lines.push(' // ==========================================');
const inlineComment = buildInlineOptionComment(String(key), value);
const renderedValue = renderValue(value, 2, String(key));
if (renderedValue.startsWith('\n')) {
lines.push(
` ${JSON.stringify(key)}: /* ${inlineComment} */ ${renderedValue}${isLast ? '' : ','}`,
);
} else {
lines.push(
` ${JSON.stringify(key)}: ${renderedValue}${isLast ? '' : ','} // ${inlineComment}`,
);
}
return lines.join('\n');
}
export function generateConfigTemplate(
config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG),
): string {
const lines: string[] = [];
lines.push('/**');
lines.push(' * SubMiner Example Configuration File');
lines.push(' *');
lines.push(' * This file is auto-generated from src/config/definitions.ts.');
lines.push(
' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.',
);
lines.push(' */');
lines.push('{');
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
lines.push('');
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
lines.push(
renderSection(
section.key,
config[section.key],
index === CONFIG_TEMPLATE_SECTIONS.length - 1,
comments,
),
);
});
lines.push('}');
lines.push('');
return lines.join('\n');
}

19
src/config/warnings.ts Normal file
View File

@@ -0,0 +1,19 @@
import { ConfigValidationWarning } from '../types';
export interface WarningCollector {
warnings: ConfigValidationWarning[];
warn(path: string, value: unknown, fallback: unknown, message: string): void;
}
export function createWarningCollector(): WarningCollector {
const warnings: ConfigValidationWarning[] = [];
const warn = (path: string, value: unknown, fallback: unknown, message: string): void => {
warnings.push({
path,
value,
fallback,
message,
});
};
return { warnings, warn };
}