mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
feat(macos): configuration window + curl-backed macOS updater (#71)
This commit is contained in:
@@ -2324,7 +2324,10 @@ test('template generator includes known keys', () => {
|
||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
|
||||
assert.match(
|
||||
output,
|
||||
/"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
@@ -13,6 +14,77 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
const visit = (value: unknown, prefix: string): void => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of entries) {
|
||||
visit(child, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
};
|
||||
visit(config, '');
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// DEFAULT_CONFIG leaves that intentionally do not have a curated
|
||||
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
|
||||
// includes these paths, but their inline comments fall back to an auto-
|
||||
// humanized key name instead of a written description.
|
||||
//
|
||||
// Current intentional gaps:
|
||||
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
|
||||
// CSS reference is the canonical documentation surface.
|
||||
// - keybindings: an array of {key, command} objects, documented at the
|
||||
// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf.
|
||||
//
|
||||
// New leaves added to DEFAULT_CONFIG should prefer a registry entry over
|
||||
// an allowlist entry. Only allowlist a path when the registry is genuinely
|
||||
// the wrong surface for it.
|
||||
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'keybindings',
|
||||
'subtitleStyle.backdropFilter',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.fontFamily',
|
||||
'subtitleStyle.fontKerning',
|
||||
'subtitleStyle.fontSize',
|
||||
'subtitleStyle.fontStyle',
|
||||
'subtitleStyle.fontWeight',
|
||||
'subtitleStyle.jlptColors.N1',
|
||||
'subtitleStyle.jlptColors.N2',
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.fontFamily',
|
||||
'subtitleStyle.secondary.fontKerning',
|
||||
'subtitleStyle.secondary.fontSize',
|
||||
'subtitleStyle.secondary.fontStyle',
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
@@ -40,6 +112,35 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
const missing = leaves
|
||||
.filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`,
|
||||
);
|
||||
|
||||
const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`,
|
||||
);
|
||||
|
||||
const leafSet = new Set(leaves);
|
||||
const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
orphaned,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
|
||||
@@ -322,6 +322,46 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.texthooker.launchAtStartup,
|
||||
description: 'Launch texthooker server automatically when SubMiner starts.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.openBrowser',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.texthooker.openBrowser,
|
||||
description: 'Open the texthooker page in the default browser when the server starts.',
|
||||
},
|
||||
{
|
||||
path: 'subtitlePosition.yPercent',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.subtitlePosition.yPercent,
|
||||
description:
|
||||
'Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.',
|
||||
},
|
||||
{
|
||||
path: 'auto_start_overlay',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.auto_start_overlay,
|
||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.secondarySubLanguages',
|
||||
kind: 'array',
|
||||
defaultValue: defaultConfig.secondarySub.secondarySubLanguages,
|
||||
description:
|
||||
'Language code priority list used to auto-select a secondary subtitle track when available.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.autoLoadSecondarySub',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.secondarySub.autoLoadSecondarySub,
|
||||
description:
|
||||
'Automatically load a matching secondary subtitle when the primary subtitle loads.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.defaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['hidden', 'visible', 'hover'],
|
||||
defaultValue: defaultConfig.secondarySub.defaultMode,
|
||||
description: 'Default visibility mode for the secondary subtitle bar.',
|
||||
},
|
||||
{
|
||||
path: 'websocket.enabled',
|
||||
kind: 'enum',
|
||||
@@ -360,6 +400,27 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subsync.replace,
|
||||
description: 'Replace the active subtitle file when sync completes.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.alass_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.alass_path,
|
||||
description:
|
||||
'Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffsubsync_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffsubsync_path,
|
||||
description:
|
||||
'Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.ffmpeg_path',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subsync.ffmpeg_path,
|
||||
description:
|
||||
'Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.',
|
||||
},
|
||||
{
|
||||
path: 'startupWarmups.lowPowerMode',
|
||||
kind: 'boolean',
|
||||
@@ -422,5 +483,112 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
|
||||
description: 'Timeout for multi-copy/mine modes.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleVisibleOverlayGlobal,
|
||||
description:
|
||||
'Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitle',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitle,
|
||||
description: 'Accelerator that copies the current subtitle line to the clipboard.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.copySubtitleMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.copySubtitleMultiple,
|
||||
description:
|
||||
'Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.updateLastCardFromClipboard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.updateLastCardFromClipboard,
|
||||
description:
|
||||
'Accelerator that updates the last mined Anki card using the current clipboard contents.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerFieldGrouping',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerFieldGrouping,
|
||||
description: 'Accelerator that triggers Kiku field grouping on duplicate cards.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.triggerSubsync',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.triggerSubsync,
|
||||
description: 'Accelerator that triggers subsync against the active subtitle file.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentence,
|
||||
description: 'Accelerator that mines the current sentence as a new Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.mineSentenceMultiple',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.mineSentenceMultiple,
|
||||
description:
|
||||
'Accelerator that mines consecutive sentences while the multi-mine window stays open.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSecondarySub',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSecondarySub,
|
||||
description: 'Accelerator that toggles the secondary subtitle bar visibility.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.markAudioCard',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.markAudioCard,
|
||||
description: 'Accelerator that marks the last mined card as an audio card.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openCharacterDictionary',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
|
||||
description: 'Accelerator that opens the character dictionary modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openRuntimeOptions',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openRuntimeOptions,
|
||||
description: 'Accelerator that opens the runtime options modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openJimaku',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openJimaku,
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerSelect',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerSelect,
|
||||
description: 'Accelerator that opens the controller selection and learn-mode modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openControllerDebug',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openControllerDebug,
|
||||
description:
|
||||
'Accelerator that opens the controller debug modal with live axis/button readouts.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.toggleSubtitleSidebar',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
|
||||
description: 'Accelerator that toggles the subtitle sidebar visibility.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.enabled,
|
||||
description: 'Enable AnkiConnect integration.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.url',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.url,
|
||||
description: 'Base URL of the AnkiConnect HTTP server.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.pollingRate',
|
||||
kind: 'number',
|
||||
@@ -58,6 +64,37 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.fields.word,
|
||||
description: 'Card field for the mined word or expression text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.audio',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.audio,
|
||||
description: 'Card field that receives generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.image',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.image,
|
||||
description: 'Card field that receives the captured screenshot or animated image.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.sentence',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.sentence,
|
||||
description: 'Card field that receives the source sentence text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.miscInfo',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.miscInfo,
|
||||
description:
|
||||
'Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.translation',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.fields.translation,
|
||||
description: 'Card field that receives the current selection or translated text.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.ai.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -83,6 +120,41 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Automatically update newly added cards.',
|
||||
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteAudio,
|
||||
description:
|
||||
'When updating an existing card, overwrite the audio field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.overwriteImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.overwriteImage,
|
||||
description:
|
||||
'When updating an existing card, overwrite the image field instead of skipping it.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.mediaInsertMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['append', 'prepend'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.mediaInsertMode,
|
||||
description:
|
||||
'Whether new media is appended after or prepended before existing field contents on update.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.highlightWord',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.highlightWord,
|
||||
description: 'Bold the mined word inside the sentence field on the saved Anki card.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.behavior.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['osd', 'system', 'both', 'none'],
|
||||
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
|
||||
description: 'Notification surface used to announce mining and update outcomes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
|
||||
kind: 'boolean',
|
||||
@@ -90,6 +162,97 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateImage,
|
||||
description: 'Generate screenshot or animated image for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageType',
|
||||
kind: 'enum',
|
||||
enumValues: ['static', 'avif'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageType,
|
||||
description:
|
||||
'Image capture type: "static" for a single still frame, "avif" for an animated AVIF.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageFormat',
|
||||
kind: 'enum',
|
||||
enumValues: ['jpg', 'png', 'webp'],
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageFormat,
|
||||
description: 'Encoding format used when imageType is "static".',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageQuality',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageQuality,
|
||||
description: 'Quality (0-100) used for lossy static image encoders.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
|
||||
description:
|
||||
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.imageMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedFps',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedFps,
|
||||
description: 'Target frame rate for animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxWidth',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxWidth,
|
||||
description: 'Maximum width applied to animated AVIF captures.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedMaxHeight',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
|
||||
description:
|
||||
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.animatedCrf',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.animatedCrf,
|
||||
description:
|
||||
'Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.audioPadding',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.audioPadding,
|
||||
description: 'Seconds of padding appended to both ends of generated sentence audio.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.fallbackDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.fallbackDuration,
|
||||
description: 'Fallback clip duration in seconds when subtitle timing data is unavailable.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.maxMediaDuration',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
|
||||
description: 'Maximum allowed media clip duration in seconds.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.matchMode',
|
||||
kind: 'enum',
|
||||
@@ -148,6 +311,44 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Kiku duplicate-card field grouping mode.',
|
||||
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.enabled,
|
||||
description: 'Enable Kiku-specific mining behaviors (duplicate handling, field grouping).',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.deleteDuplicateInAuto',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isKiku.deleteDuplicateInAuto,
|
||||
description:
|
||||
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.enabled,
|
||||
description: 'Enable Lapis-specific mining behaviors and sentence card model targeting.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isLapis.sentenceCardModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||
description: 'Note type name used by Lapis sentence cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.metadata.pattern',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.metadata.pattern,
|
||||
description:
|
||||
'Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jimaku.apiBaseUrl,
|
||||
description: 'Base URL of the Jimaku subtitle search API.',
|
||||
},
|
||||
{
|
||||
path: 'jimaku.languagePreference',
|
||||
kind: 'enum',
|
||||
@@ -277,6 +478,26 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -387,6 +608,18 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ai.baseUrl,
|
||||
description: 'Base URL for the shared OpenAI-compatible AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.model',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.model,
|
||||
description: 'Default model identifier requested from the shared AI provider.',
|
||||
},
|
||||
{
|
||||
path: 'ai.systemPrompt',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ai.systemPrompt,
|
||||
description: 'Default system prompt sent with shared AI provider requests.',
|
||||
},
|
||||
{
|
||||
path: 'ai.requestTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parse } from 'jsonc-parser';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import { applyConfigSettingsPatchToContent, buildConfigSettingsSnapshot } from './jsonc-edit';
|
||||
import { buildConfigSettingsRegistry } from './registry';
|
||||
|
||||
test('applyConfigSettingsPatchToContent preserves JSONC comments while setting nested values', () => {
|
||||
const input = `{
|
||||
// keep this comment
|
||||
"subtitleStyle": {
|
||||
"fontSize": 35,
|
||||
},
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.match(result.content, /keep this comment/);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(parsed.subtitleStyle.autoPauseVideoOnHover, false);
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 35);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
|
||||
const input = `{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 41,
|
||||
"autoPauseVideoOnHover": false
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: input,
|
||||
operations: [{ op: 'reset', path: 'subtitleStyle.autoPauseVideoOnHover' }],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const parsed = parse(result.content);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'autoPauseVideoOnHover'), false);
|
||||
assert.equal(parsed.subtitleStyle.fontSize, 41);
|
||||
});
|
||||
|
||||
test('applyConfigSettingsPatchToContent rejects warnings caused by modified fields', () => {
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: '{}',
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: 'bad',
|
||||
},
|
||||
],
|
||||
previousWarnings: [],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.warnings[0]?.path, 'subtitleStyle.autoPauseVideoOnHover');
|
||||
});
|
||||
|
||||
test('buildConfigSettingsSnapshot masks configured secret values', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const snapshot = buildConfigSettingsSnapshot({
|
||||
configPath: '/tmp/config.jsonc',
|
||||
rawConfig: {
|
||||
ai: {
|
||||
apiKey: 'secret-key',
|
||||
},
|
||||
},
|
||||
resolvedConfig: {
|
||||
...DEFAULT_CONFIG,
|
||||
ai: {
|
||||
...DEFAULT_CONFIG.ai,
|
||||
apiKey: 'secret-key',
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
fields,
|
||||
});
|
||||
|
||||
const apiKey = snapshot.values['ai.apiKey'];
|
||||
assert.deepEqual(apiKey, { configured: true });
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
type FormattingOptions,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshot,
|
||||
} from '../../types/settings';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { getConfigValueAtPath } from './registry';
|
||||
|
||||
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
eol: '\n',
|
||||
};
|
||||
|
||||
export type ConfigSettingsPatchApplyResult =
|
||||
| {
|
||||
ok: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
content: string;
|
||||
warnings: ConfigValidationWarning[];
|
||||
error: string;
|
||||
};
|
||||
|
||||
interface ApplyConfigSettingsPatchOptions {
|
||||
content: string;
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
previousWarnings: ConfigValidationWarning[];
|
||||
}
|
||||
|
||||
interface BuildConfigSettingsSnapshotOptions {
|
||||
configPath: string;
|
||||
rawConfig: RawConfig;
|
||||
resolvedConfig: ResolvedConfig;
|
||||
warnings: ConfigValidationWarning[];
|
||||
fields: ConfigSettingsField[];
|
||||
}
|
||||
|
||||
function pathToSegments(path: string): string[] {
|
||||
return path.split('.').filter(Boolean);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function warningBelongsToModifiedPath(
|
||||
warning: ConfigValidationWarning,
|
||||
operation: ConfigSettingsPatchOperation,
|
||||
): boolean {
|
||||
return (
|
||||
pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path)
|
||||
);
|
||||
}
|
||||
|
||||
function warningIdentity(warning: ConfigValidationWarning): string {
|
||||
return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`;
|
||||
}
|
||||
|
||||
function parseRawConfig(content: string): RawConfig {
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parseJsonc(content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
|
||||
}
|
||||
return isRecord(parsed) ? (parsed as RawConfig) : {};
|
||||
}
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
return content.trim().length > 0 ? content : '{}\n';
|
||||
}
|
||||
|
||||
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
|
||||
const edits = modify(
|
||||
content,
|
||||
pathToSegments(operation.path),
|
||||
operation.op === 'reset' ? undefined : operation.value,
|
||||
{
|
||||
formattingOptions: JSONC_FORMATTING_OPTIONS,
|
||||
getInsertionIndex: (properties) => properties.length,
|
||||
},
|
||||
);
|
||||
return applyEdits(content, edits);
|
||||
}
|
||||
|
||||
function collectModifiedWarnings(
|
||||
warnings: ConfigValidationWarning[],
|
||||
operations: ConfigSettingsPatchOperation[],
|
||||
previousWarnings: ConfigValidationWarning[],
|
||||
): ConfigValidationWarning[] {
|
||||
const previous = new Set(previousWarnings.map(warningIdentity));
|
||||
return warnings.filter((warning) => {
|
||||
if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) {
|
||||
return false;
|
||||
}
|
||||
return !previous.has(warningIdentity(warning));
|
||||
});
|
||||
}
|
||||
|
||||
export function applyConfigSettingsPatchToContent(
|
||||
options: ApplyConfigSettingsPatchOptions,
|
||||
): ConfigSettingsPatchApplyResult {
|
||||
let content = normalizeContent(options.content);
|
||||
|
||||
try {
|
||||
parseRawConfig(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : 'Invalid JSONC.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
for (const operation of options.operations) {
|
||||
content = applySingleOperation(content, operation);
|
||||
}
|
||||
|
||||
const rawConfig = parseRawConfig(content);
|
||||
const { resolved, warnings } = resolveConfig(rawConfig);
|
||||
const modifiedWarnings = collectModifiedWarnings(
|
||||
warnings,
|
||||
options.operations,
|
||||
options.previousWarnings,
|
||||
);
|
||||
if (modifiedWarnings.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: modifiedWarnings,
|
||||
error: 'One or more modified settings failed validation.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
content,
|
||||
rawConfig,
|
||||
resolvedConfig: resolved,
|
||||
warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
content,
|
||||
warnings: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to update config content.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildConfigSettingsSnapshot(
|
||||
options: BuildConfigSettingsSnapshotOptions,
|
||||
): ConfigSettingsSnapshot {
|
||||
const values: Record<string, unknown> = {};
|
||||
|
||||
for (const field of options.fields) {
|
||||
const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath);
|
||||
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath);
|
||||
if (field.secret) {
|
||||
values[field.configPath] = {
|
||||
configured:
|
||||
(typeof rawValue === 'string' && rawValue.length > 0) ||
|
||||
(typeof resolvedValue === 'string' && resolvedValue.length > 0),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
||||
}
|
||||
|
||||
return {
|
||||
configPath: options.configPath,
|
||||
fields: options.fields,
|
||||
values,
|
||||
warnings: options.warnings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import {
|
||||
buildConfigSettingsRegistry,
|
||||
getConfigSettingsCoverage,
|
||||
LEGACY_HIDDEN_CONFIG_PATHS,
|
||||
} from './registry';
|
||||
|
||||
test('config settings registry places hover pause under viewing playback behavior', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const hoverPause = fields.find(
|
||||
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
|
||||
);
|
||||
|
||||
assert.ok(hoverPause);
|
||||
assert.equal(hoverPause.category, 'viewing');
|
||||
assert.equal(hoverPause.section, 'Playback pause behavior');
|
||||
assert.equal(hoverPause.control, 'boolean');
|
||||
});
|
||||
|
||||
test('config settings registry hides legacy and ignored paths from normal fields', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const visiblePaths = new Set(
|
||||
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
|
||||
);
|
||||
|
||||
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
|
||||
assert.equal(visiblePaths.has(path), false, path);
|
||||
}
|
||||
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
|
||||
});
|
||||
|
||||
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
|
||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
|
||||
|
||||
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type {
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsControl,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsRestartBehavior,
|
||||
} from '../../types/settings';
|
||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||
|
||||
type Leaf = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'ankiConnect.deck',
|
||||
'ankiConnect.wordField',
|
||||
'ankiConnect.audioField',
|
||||
'ankiConnect.imageField',
|
||||
'ankiConnect.sentenceField',
|
||||
'ankiConnect.miscInfoField',
|
||||
'ankiConnect.miscInfoPattern',
|
||||
'ankiConnect.generateAudio',
|
||||
'ankiConnect.generateImage',
|
||||
'ankiConnect.imageType',
|
||||
'ankiConnect.imageFormat',
|
||||
'ankiConnect.imageQuality',
|
||||
'ankiConnect.imageMaxWidth',
|
||||
'ankiConnect.imageMaxHeight',
|
||||
'ankiConnect.animatedFps',
|
||||
'ankiConnect.animatedMaxWidth',
|
||||
'ankiConnect.animatedMaxHeight',
|
||||
'ankiConnect.animatedCrf',
|
||||
'ankiConnect.syncAnimatedImageToWordAudio',
|
||||
'ankiConnect.audioPadding',
|
||||
'ankiConnect.fallbackDuration',
|
||||
'ankiConnect.maxMediaDuration',
|
||||
'ankiConnect.overwriteAudio',
|
||||
'ankiConnect.overwriteImage',
|
||||
'ankiConnect.mediaInsertMode',
|
||||
'ankiConnect.highlightWord',
|
||||
'ankiConnect.notificationType',
|
||||
'ankiConnect.autoUpdateNewCards',
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||
'youtubeSubgen.primarySubLanguages',
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
'jellyfin.accessToken',
|
||||
'jellyfin.userId',
|
||||
'controller.buttonIndices',
|
||||
] as const;
|
||||
|
||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
|
||||
|
||||
const JSON_OBJECT_FIELDS = new Set([
|
||||
'keybindings',
|
||||
'controller.bindings',
|
||||
'controller.profiles',
|
||||
'ankiConnect.knownWords.decks',
|
||||
]);
|
||||
|
||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||
|
||||
const COLOR_SUFFIXES = new Set([
|
||||
'Color',
|
||||
'color',
|
||||
'backgroundColor',
|
||||
'singleColor',
|
||||
'knownWordColor',
|
||||
'nPlusOne',
|
||||
]);
|
||||
|
||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
function isLegacyHidden(path: string): boolean {
|
||||
return (
|
||||
LEGACY_HIDDEN_CONFIG_PATHS.some((hiddenPath) => pathStartsWith(path, hiddenPath)) ||
|
||||
EXCLUDED_PREFIXES.some((prefix) => pathStartsWith(path, prefix))
|
||||
);
|
||||
}
|
||||
|
||||
function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
|
||||
if (JSON_OBJECT_FIELDS.has(prefix)) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: prefix, value }];
|
||||
}
|
||||
return entries.flatMap(([key, child]) =>
|
||||
flattenConfigLeaves(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
return prefix ? [{ path: prefix, value }] : [];
|
||||
}
|
||||
|
||||
function humanizePath(path: string): string {
|
||||
const key = path.split('.').at(-1) ?? path;
|
||||
const spaced = key
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\bai\b/i, 'AI')
|
||||
.replace(/\bmpv\b/i, 'mpv')
|
||||
.replace(/\byomitan\b/i, 'Yomitan')
|
||||
.replace(/\bjimaku\b/i, 'Jimaku')
|
||||
.replace(/\banilist\b/i, 'AniList')
|
||||
.replace(/\banki\b/i, 'Anki');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Playback pause behavior' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ankiConnect.knownWords.') ||
|
||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||
path === 'subtitleStyle.enableJlpt' ||
|
||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||
path === 'subtitleStyle.nameMatchColor'
|
||||
) {
|
||||
return { category: 'viewing', section: 'Annotation display' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
||||
return { category: 'viewing', section: 'Secondary subtitle appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.')) {
|
||||
return { category: 'viewing', section: 'Primary subtitle appearance' };
|
||||
}
|
||||
if (path.startsWith('subtitleSidebar.')) {
|
||||
return { category: 'viewing', section: 'Subtitle sidebar' };
|
||||
}
|
||||
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
||||
return { category: 'viewing', section: 'Subtitle behavior' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.fields.')) {
|
||||
return { category: 'mining-anki', section: 'Note fields' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.media.')) {
|
||||
return { category: 'mining-anki', section: 'Media capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
return { category: 'mining-anki', section: 'Kiku and Lapis' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
return { category: 'mining-anki', section: 'Anki AI' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.proxy.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.')) {
|
||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('mpv.') ||
|
||||
path.startsWith('youtube.') ||
|
||||
path.startsWith('youtubeSubgen.') ||
|
||||
path.startsWith('jimaku.') ||
|
||||
path.startsWith('subsync.')
|
||||
) {
|
||||
return { category: 'playback-sources', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('shortcuts.')) {
|
||||
return { category: 'input', section: 'Overlay shortcuts' };
|
||||
}
|
||||
if (path === 'keybindings') {
|
||||
return { category: 'input', section: 'MPV keybindings' };
|
||||
}
|
||||
if (path.startsWith('controller.')) {
|
||||
return { category: 'input', section: 'Controller' };
|
||||
}
|
||||
if (
|
||||
path.startsWith('ai.') ||
|
||||
path.startsWith('anilist.') ||
|
||||
path.startsWith('yomitan.') ||
|
||||
path.startsWith('jellyfin.') ||
|
||||
path.startsWith('discordPresence.') ||
|
||||
path.startsWith('websocket.') ||
|
||||
path.startsWith('annotationWebsocket.') ||
|
||||
path.startsWith('texthooker.')
|
||||
) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (
|
||||
path.startsWith('immersionTracking.') ||
|
||||
path.startsWith('stats.') ||
|
||||
path.startsWith('updates.') ||
|
||||
path.startsWith('startupWarmups.') ||
|
||||
path.startsWith('logging.') ||
|
||||
path === 'auto_start_overlay'
|
||||
) {
|
||||
return { category: 'tracking-app', section: topSection(path) };
|
||||
}
|
||||
return { category: 'advanced', section: 'Advanced' };
|
||||
}
|
||||
|
||||
function topSection(path: string): string {
|
||||
const top = path.split('.')[0] ?? path;
|
||||
const labels: Record<string, string> = {
|
||||
ai: 'Shared AI provider',
|
||||
anilist: 'AniList',
|
||||
annotationWebsocket: 'Annotation WebSocket',
|
||||
discordPresence: 'Discord Rich Presence',
|
||||
immersionTracking: 'Immersion tracking',
|
||||
jimaku: 'Jimaku',
|
||||
jellyfin: 'Jellyfin',
|
||||
logging: 'Logging',
|
||||
mpv: 'mpv launcher',
|
||||
stats: 'Stats dashboard',
|
||||
startupWarmups: 'Startup warmups',
|
||||
subsync: 'Auto subtitle sync',
|
||||
texthooker: 'Texthooker',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
youtube: 'YouTube playback',
|
||||
youtubeSubgen: 'YouTube subtitle generation',
|
||||
auto_start_overlay: 'Overlay startup',
|
||||
};
|
||||
return labels[top] ?? humanizePath(top);
|
||||
}
|
||||
|
||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||
if (SECRET_PATHS.has(path)) return 'secret';
|
||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||
if (Array.isArray(value)) return 'string-list';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'string') {
|
||||
const leaf = path.split('.').at(-1) ?? path;
|
||||
if ([...COLOR_SUFFIXES].some((suffix) => leaf.endsWith(suffix))) return 'color';
|
||||
if (leaf.toLowerCase().includes('prompt')) return 'textarea';
|
||||
return 'text';
|
||||
}
|
||||
return 'json';
|
||||
}
|
||||
|
||||
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
if (
|
||||
path === 'keybindings' ||
|
||||
pathStartsWith(path, 'shortcuts') ||
|
||||
pathStartsWith(path, 'subtitleStyle') ||
|
||||
pathStartsWith(path, 'subtitleSidebar') ||
|
||||
path === 'secondarySub.defaultMode' ||
|
||||
pathStartsWith(path, 'ankiConnect.ai')
|
||||
) {
|
||||
return 'hot-reload';
|
||||
}
|
||||
return 'restart';
|
||||
}
|
||||
|
||||
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||
const option = OPTION_BY_PATH.get(leaf.path);
|
||||
const { category, section } = categoryAndSection(leaf.path);
|
||||
return {
|
||||
id: leaf.path,
|
||||
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
|
||||
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
|
||||
configPath: leaf.path,
|
||||
category,
|
||||
section,
|
||||
control: controlForPath(leaf.path, leaf.value),
|
||||
defaultValue: leaf.value,
|
||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||
restartBehavior: restartBehaviorForPath(leaf.path),
|
||||
advanced:
|
||||
leaf.path.startsWith('controller.') ||
|
||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||
leaf.path.startsWith('youtubeSubgen.'),
|
||||
secret: SECRET_PATHS.has(leaf.path),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfigSettingsRegistry(
|
||||
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
||||
): ConfigSettingsField[] {
|
||||
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
||||
return leaves.map(fieldForLeaf).sort((a, b) => {
|
||||
const category = a.category.localeCompare(b.category);
|
||||
if (category !== 0) return category;
|
||||
const section = a.section.localeCompare(b.section);
|
||||
if (section !== 0) return section;
|
||||
return a.configPath.localeCompare(b.configPath);
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfigSettingsCoverage(
|
||||
defaultConfig: ResolvedConfig,
|
||||
fields: ConfigSettingsField[],
|
||||
): { uncoveredDefaultPaths: string[] } {
|
||||
const visibleFields = fields.filter((field) => !field.legacyHidden);
|
||||
const uncoveredDefaultPaths = flattenConfigLeaves(defaultConfig)
|
||||
.map((leaf) => leaf.path)
|
||||
.filter((path) => !isLegacyHidden(path))
|
||||
.filter(
|
||||
(path) =>
|
||||
!visibleFields.some(
|
||||
(field) =>
|
||||
field.configPath === path ||
|
||||
(field.control === 'json' && pathStartsWith(path, field.configPath)),
|
||||
),
|
||||
)
|
||||
.sort();
|
||||
|
||||
return { uncoveredDefaultPaths };
|
||||
}
|
||||
|
||||
export function getConfigValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
Reference in New Issue
Block a user