mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
1112
src/config/config.test.ts
Normal file
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
101
src/config/definitions.ts
Normal 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;
|
||||
}
|
||||
61
src/config/definitions/defaults-core.ts
Normal file
61
src/config/definitions/defaults-core.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
20
src/config/definitions/defaults-immersion.ts
Normal file
20
src/config/definitions/defaults-immersion.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
113
src/config/definitions/defaults-integrations.ts
Normal file
113
src/config/definitions/defaults-integrations.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
42
src/config/definitions/defaults-subtitle.ts
Normal file
42
src/config/definitions/defaults-subtitle.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
59
src/config/definitions/domain-registry.test.ts
Normal file
59
src/config/definitions/domain-registry.test.ts
Normal 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)));
|
||||
}
|
||||
});
|
||||
49
src/config/definitions/options-core.ts
Normal file
49
src/config/definitions/options-core.ts
Normal 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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
82
src/config/definitions/options-immersion.ts
Normal file
82
src/config/definitions/options-immersion.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
235
src/config/definitions/options-integrations.ts
Normal file
235
src/config/definitions/options-integrations.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
72
src/config/definitions/options-subtitle.ts
Normal file
72
src/config/definitions/options-subtitle.ts
Normal 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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
56
src/config/definitions/runtime-options.ts
Normal file
56
src/config/definitions/runtime-options.ts
Normal 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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
61
src/config/definitions/shared.ts
Normal file
61
src/config/definitions/shared.ts
Normal 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'] },
|
||||
];
|
||||
154
src/config/definitions/template-sections.ts
Normal file
154
src/config/definitions/template-sections.ts
Normal 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
3
src/config/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './definitions';
|
||||
export * from './service';
|
||||
export * from './template';
|
||||
65
src/config/load.ts
Normal file
65
src/config/load.ts
Normal 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
17
src/config/parse.ts
Normal 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;
|
||||
}
|
||||
89
src/config/path-resolution.test.ts
Normal file
89
src/config/path-resolution.test.ts
Normal 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'));
|
||||
});
|
||||
76
src/config/path-resolution.ts
Normal file
76
src/config/path-resolution.ts
Normal 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
33
src/config/resolve.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
68
src/config/resolve/anki-connect.test.ts
Normal file
68
src/config/resolve/anki-connect.test.ts
Normal 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'));
|
||||
});
|
||||
728
src/config/resolve/anki-connect.ts
Normal file
728
src/config/resolve/anki-connect.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
src/config/resolve/context.ts
Normal file
30
src/config/resolve/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
179
src/config/resolve/core-domains.ts
Normal file
179
src/config/resolve/core-domains.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/config/resolve/immersion-tracking.ts
Normal file
173
src/config/resolve/immersion-tracking.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/config/resolve/integrations.ts
Normal file
128
src/config/resolve/integrations.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/config/resolve/jellyfin.test.ts
Normal file
64
src/config/resolve/jellyfin.test.ts
Normal 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'));
|
||||
});
|
||||
38
src/config/resolve/shared.ts
Normal file
38
src/config/resolve/shared.ts
Normal 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];
|
||||
}
|
||||
239
src/config/resolve/subtitle-domains.ts
Normal file
239
src/config/resolve/subtitle-domains.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/config/resolve/subtitle-style.test.ts
Normal file
29
src/config/resolve/subtitle-style.test.ts
Normal 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.',
|
||||
),
|
||||
);
|
||||
});
|
||||
28
src/config/resolve/top-level.ts
Normal file
28
src/config/resolve/top-level.ts
Normal 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
116
src/config/service.ts
Normal 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
135
src/config/template.ts
Normal 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
19
src/config/warnings.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user