feat(config): add configuration window (#70)

This commit is contained in:
2026-05-21 04:16:21 -07:00
committed by GitHub
parent a54f03f0cd
commit dc52bc2fba
287 changed files with 14507 additions and 8134 deletions
+64
View File
@@ -32,6 +32,70 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent removes duplicate properties across JSONC trivia', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": false
} /* old value */ ,
// effective value follows
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
+162 -2
View File
@@ -2,6 +2,9 @@ import {
applyEdits,
modify,
parse as parseJsonc,
parseTree as parseJsoncTree,
type Edit,
type Node as JsoncNode,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
@@ -12,7 +15,7 @@ import type {
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
import { getConfigValueAtPath, SECRET_PATHS } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
content = removeDuplicatePropertiesAlongPath(content, operation.path);
const edits = modify(
content,
pathToSegments(operation.path),
@@ -103,6 +107,148 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
return applyEdits(content, edits);
}
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
return propertyNode.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function isWhitespace(value: string | undefined): boolean {
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
}
function nextNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index < content.length) {
if (isWhitespace(content[index])) {
index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '/') {
index += 2;
while (index < content.length && content[index] !== '\n') index += 1;
continue;
}
if (content[index] === '/' && content[index + 1] === '*') {
index += 2;
while (
index + 1 < content.length &&
!(content[index] === '*' && content[index + 1] === '/')
) {
index += 1;
}
index = Math.min(content.length, index + 2);
continue;
}
break;
}
return index;
}
function previousNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index >= 0) {
if (isWhitespace(content[index])) {
index -= 1;
continue;
}
const lineStart = content.lastIndexOf('\n', index) + 1;
const linePrefix = content.slice(lineStart, index + 1);
const lineCommentStart = linePrefix.lastIndexOf('//');
if (lineCommentStart >= 0 && /^[ \t]*$/.test(linePrefix.slice(0, lineCommentStart))) {
index = lineStart - 1;
continue;
}
if (content[index] === '/' && content[index - 1] === '*') {
index -= 2;
while (index > 0 && !(content[index - 1] === '/' && content[index] === '*')) {
index -= 1;
}
index -= 2;
continue;
}
break;
}
return index;
}
function lineStartOffset(content: string, offset: number): number {
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
}
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
let offset = propertyNode.offset;
let end = propertyNode.offset + propertyNode.length;
const next = nextNonWhitespaceOffset(content, end);
if (content[next] === ',') {
end = next + 1;
const lineStart = lineStartOffset(content, offset);
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
offset = lineStart;
}
} else {
const previous = previousNonWhitespaceOffset(content, offset - 1);
if (content[previous] === ',') {
offset = previous;
}
}
return {
offset,
length: Math.max(0, end - offset),
content: '',
};
}
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
const errors: ParseError[] = [];
let node = parseJsoncTree(content, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!node || errors.length > 0) {
return [];
}
const edits: Edit[] = [];
for (const segment of pathToSegments(path)) {
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
if (matches.length === 0) {
break;
}
for (const duplicate of matches.slice(0, -1)) {
edits.push(removalEditForProperty(content, duplicate));
}
node = propertyValue(matches[matches.length - 1]!);
}
return edits;
}
function applyRemovalEdits(content: string, edits: Edit[]): string {
return [...edits]
.sort((left, right) => right.offset - left.offset)
.reduce(
(current, edit) =>
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
content,
);
}
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
const edits = collectDuplicatePropertyRemovalEdits(content, path);
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
@@ -188,7 +334,21 @@ export function buildConfigSettingsSnapshot(
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
}
for (const secretPath of SECRET_PATHS) {
if (Object.hasOwn(values, secretPath)) {
continue;
}
const rawValue = getConfigValueAtPath(options.rawConfig, secretPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, secretPath);
if (
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0)
) {
values[secretPath] = { configured: true };
}
}
return {
+294 -28
View File
@@ -1,39 +1,305 @@
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';
import { buildConfigSettingsRegistry } 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',
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
function field(path: string) {
const match = fields.find((candidate) => candidate.configPath === path);
assert.ok(match, `missing settings field: ${path}`);
return match;
}
test('settings registry splits viewing into appearance and behavior categories', () => {
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
assert.equal(field('auto_start_overlay').category, 'behavior');
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
assert.equal(field('mpv.launchMode').category, 'behavior');
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
assert.ok(
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
);
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);
test('settings registry groups playback startup controls under playback behavior', () => {
for (const path of [
'subtitleStyle.autoPauseVideoOnHover',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'subtitleSidebar.pauseVideoOnHover',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
]) {
assert.equal(field(path).category, 'behavior', path);
assert.equal(field(path).section, 'Playback Behavior', 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, []);
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
});
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
]) {
assert.equal(
fields.some((candidate) => candidate.configPath === path),
false,
path,
);
}
});
test('settings registry orders websocket server immediately after annotation websocket', () => {
const integrationSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'integrations')
.map((candidate) => candidate.section),
),
];
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
});
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
assert.equal(field('websocket.enabled').defaultValue, false);
assert.equal(
field('websocket.enabled').description,
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
);
});
test('settings registry places immersion tracking after other tracking and app sections', () => {
const trackingSections = [
...new Set(
fields
.filter((candidate) => candidate.category === 'tracking-app')
.map((candidate) => candidate.section),
),
];
assert.equal(trackingSections.at(-1), 'Immersion tracking');
});
test('settings registry groups annotation display fields by config group', () => {
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words');
assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1');
assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT');
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
assert.equal(
field('subtitleStyle.frequencyDictionary.sourcePath').section,
'Frequency Highlighting',
);
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
});
test('settings registry exposes mpv aniskip button as an mpv key learn control', () => {
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
});
test('settings registry exposes specialized controls for config-assisted inputs', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
assert.equal(field('keybindings').control, 'mpv-keybindings');
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
assert.equal(field('subtitleSidebar.css').control, 'css-declarations');
assert.equal(field('stats.toggleKey').control, 'key-code');
assert.equal(field('discordPresence.presenceStyle').control, 'select');
});
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
const primaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
const secondaryVisible = fields
.filter(
(candidate) =>
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleStyle.paintOrder').settingsHidden, true);
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
assert.equal(field('subtitleStyle.frequencyDictionary.bandedColors').settingsHidden, false);
});
test('settings registry exposes css declaration editor for subtitle sidebar appearance', () => {
const sidebarVisible = fields
.filter(
(candidate) =>
candidate.section === 'Subtitle Sidebar Appearance' && !candidate.settingsHidden,
)
.map((candidate) => candidate.configPath);
assert.deepEqual(sidebarVisible, ['subtitleSidebar.css']);
assert.equal(field('subtitleSidebar.fontFamily').settingsHidden, true);
assert.equal(field('subtitleSidebar.fontSize').settingsHidden, true);
assert.equal(field('subtitleSidebar.textColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.backgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.timestampColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.activeLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.hoverLineBackgroundColor').settingsHidden, true);
assert.equal(field('subtitleSidebar.enabled').settingsHidden, false);
assert.equal(field('subtitleSidebar.layout').settingsHidden, false);
});
test('settings registry routes playback-related integrations into integrations', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
assert.ok(
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') <
ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'),
);
assert.ok(
fields.findIndex((candidate) => candidate.section === 'AnkiConnect') <
fields.findIndex((candidate) => candidate.section === 'AnkiConnect Proxy'),
);
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
);
});
test('settings registry hides app-managed and inactive config surfaces', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
for (const hiddenPath of [
'ai.enabled',
'ai.apiKey',
'ai.apiKeyCommand',
'ai.model',
'ai.baseUrl',
'ai.systemPrompt',
'ai.requestTimeoutMs',
'ankiConnect.ai.enabled',
'ankiConnect.ai.model',
'ankiConnect.ai.systemPrompt',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.whisperBin',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
});
test('settings registry marks safe live config paths as hot-reloadable', () => {
for (const path of [
'mpv.aniskipButtonKey',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
'jimaku.maxEntryResults',
'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
});
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [
'stats.serverPort',
'ankiConnect.url',
'ankiConnect.proxy.enabled',
'mpv.socketPath',
'websocket.port',
]) {
assert.equal(field(path).restartBehavior, 'restart', path);
}
});
+433 -43
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
} from '../../settings/subtitle-style-css';
type Leaf = {
path: string;
@@ -46,41 +50,209 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.nPlusOne.matchMode',
'ankiConnect.nPlusOne.decks',
'ankiConnect.nPlusOne.knownWord',
'ankiConnect.nPlusOne.nPlusOne',
'ankiConnect.knownWords.color',
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'ankiConnect.fields.translation',
'controller.bindings',
'controller.preferredGamepadId',
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
'jellyfin.accessToken',
'jellyfin.userId',
'jellyfin.clientName',
'jellyfin.clientVersion',
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'jellyfin.directPlayContainers',
'jellyfin.remoteControlDeviceName',
'controller.buttonIndices',
'shortcuts.multiCopyTimeoutMs',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
const EXCLUDED_PREFIXES = [
'ai',
'ankiConnect.ai',
'controller.buttonIndices',
'youtubeSubgen',
] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set([
'Color',
'color',
'backgroundColor',
'singleColor',
'knownWordColor',
'nPlusOne',
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
...getSubtitleCssManagedConfigPaths('primary'),
...getSubtitleCssManagedConfigPaths('secondary'),
...getSubtitleCssManagedConfigPaths('sidebar'),
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'appearance',
'behavior',
'mining-anki',
'input',
'integrations',
'tracking-app',
'advanced',
];
const SECTION_ORDER = new Map<string, number>(
[
'Annotation Display',
'Known Words',
'N+1',
'Frequency Highlighting',
'Primary Subtitle Appearance',
'Secondary Subtitle Appearance',
'Subtitle Sidebar Appearance',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Sidebar Behavior',
'YouTube Playback Settings',
'mpv Playback',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Anki AI',
'AnkiConnect',
'AnkiConnect Proxy',
'Jimaku',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
'Controller',
'Annotation WebSocket',
'WebSocket server',
'AniList',
'Character Dictionary',
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Yomitan',
'Stats dashboard',
'Startup warmups',
'Logging',
'Updates',
'Immersion tracking',
].map((section, index) => [section, index]),
);
const PATH_ORDER = new Map<string, number>(
[
'ankiConnect.enabled',
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
'subtitleStyle.css',
'subtitleStyle.primaryDefaultMode',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.css',
'subtitleSidebar.css',
'secondarySub.defaultMode',
'secondarySub.secondarySubLanguages',
'mpv.autoStartSubMiner',
'auto_start_overlay',
'mpv.pauseUntilOverlayReady',
'mpv.socketPath',
'mpv.backend',
'mpv.subminerBinaryPath',
'mpv.aniskipEnabled',
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
].map((path, index) => [path, index]),
);
const SUBSECTION_ORDER = new Map<string, number>(
[
'Known Words',
'N+1',
'JLPT',
'Frequency Highlighting',
'Character Names',
'Mining & Clipboard',
'Toggle & Visibility',
'Open Panels',
'Playback',
'Default Fold State',
].map((subsection, index) => [subsection, index]),
);
const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionary': 'Open AniList Override',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'subtitleSidebar.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'subtitlePosition.yPercent': 'Subtitle Position',
'mpv.executablePath': 'mpv Executable Path',
'mpv.subminerBinaryPath': 'SubMiner Binary Path',
'mpv.socketPath': 'mpv IPC Socket Path',
'mpv.autoStartSubMiner': 'Auto-start SubMiner',
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
'mpv.aniskipEnabled': 'Enable AniSkip',
'mpv.aniskipButtonKey': 'AniSkip Button Key',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled':
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
'ankiConnect.isLapis.enabled':
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel':
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
'subtitleStyle.css':
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
'subtitleStyle.secondary.css':
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -119,6 +291,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
}
function humanizePath(path: string): string {
const override = LABEL_OVERRIDES[path];
if (override) {
return override;
}
const key = path.split('.').at(-1) ?? path;
const spaced = key
.replace(/_/g, ' ')
@@ -138,7 +314,29 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'viewing', section: 'Playback pause behavior' };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.refreshMinutes'
) {
return { category: 'behavior', section: 'Known Words' };
}
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
return { category: 'behavior', section: 'N+1' };
}
if (
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
path === 'subtitleStyle.frequencyDictionary.mode' ||
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
path === 'subtitleStyle.frequencyDictionary.topX'
) {
return { category: 'behavior', section: 'Frequency Highlighting' };
}
if (
path.startsWith('ankiConnect.knownWords.') ||
@@ -146,62 +344,87 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'viewing', section: 'Annotation display' };
return { category: 'appearance', section: 'Annotation Display' };
}
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'viewing', section: 'Secondary subtitle appearance' };
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
}
if (path === 'subtitleStyle.primaryDefaultMode') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleStyle.')) {
return { category: 'viewing', section: 'Primary subtitle appearance' };
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
}
if (path.startsWith('subtitleSidebar.')) {
return { category: 'viewing', section: 'Subtitle sidebar' };
const sidebarBehaviorPaths = new Set([
'subtitleSidebar.enabled',
'subtitleSidebar.autoOpen',
'subtitleSidebar.autoScroll',
'subtitleSidebar.layout',
]);
return sidebarBehaviorPaths.has(path)
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
: { category: 'appearance', section: 'Subtitle Sidebar Appearance' };
}
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
return { category: 'viewing', section: 'Subtitle behavior' };
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('ankiConnect.fields.')) {
return { category: 'mining-anki', section: 'Note fields' };
return { category: 'mining-anki', section: 'Note Fields' };
}
if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media capture' };
return { category: 'mining-anki', section: 'Media Capture' };
}
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku and Lapis' };
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
}
if (path.startsWith('ankiConnect.proxy.')) {
return { category: 'mining-anki', section: '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.')
path === 'auto_start_overlay' ||
path === 'mpv.autoStartSubMiner' ||
path === 'mpv.pauseUntilOverlayReady'
) {
return { category: 'playback-sources', section: topSection(path) };
return { category: 'behavior', section: 'Playback Behavior' };
}
if (path === 'mpv.aniskipButtonKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay shortcuts' };
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path === 'keybindings') {
return { category: 'input', section: 'MPV 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.') ||
@@ -211,13 +434,18 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('anilist.characterDictionary.')) {
return { category: 'integrations', section: 'Character Dictionary' };
}
if (path.startsWith('anilist.')) {
return { category: 'integrations', section: 'AniList' };
}
if (
path.startsWith('immersionTracking.') ||
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
path.startsWith('logging.')
) {
return { category: 'tracking-app', section: topSection(path) };
}
@@ -235,23 +463,40 @@ function topSection(path: string): string {
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
mpv: 'mpv Playback',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtube: 'YouTube Playback Settings',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
auto_start_overlay: 'Playback Behavior',
};
return labels[top] ?? humanizePath(top);
}
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
if (path.startsWith('ankiConnect.fields.')) return 'anki-field';
if (path.startsWith('shortcuts.'))
return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut';
if (path === 'mpv.aniskipButtonKey') return 'mpv-key';
if (
path === 'subtitleSidebar.toggleKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
) {
return 'key-code';
}
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
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';
@@ -266,6 +511,132 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'json';
}
function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
return 'JLPT';
}
if (
path === 'subtitleStyle.frequencyDictionary.enabled' ||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
return 'Character Names';
}
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
return 'Default Fold State';
}
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
return 'Default Fold State';
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return 'Toggle & Visibility';
}
if (path === 'mpv.aniskipButtonKey') {
return 'Playback';
}
if (path.startsWith('shortcuts.')) {
const leaf = path.split('.').at(-1) ?? '';
if (
leaf === 'copySubtitle' ||
leaf === 'copySubtitleMultiple' ||
leaf === 'mineSentence' ||
leaf === 'mineSentenceMultiple' ||
leaf === 'updateLastCardFromClipboard' ||
leaf === 'triggerFieldGrouping' ||
leaf === 'markAudioCard'
) {
return 'Mining & Clipboard';
}
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
) {
return 'Toggle & Visibility';
}
if (
leaf === 'openCharacterDictionary' ||
leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' ||
leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug'
) {
return 'Open Panels';
}
if (leaf === 'triggerSubsync') return 'Playback';
return undefined;
}
return undefined;
}
function isFeatureToggle(field: ConfigSettingsField): boolean {
if (field.control !== 'boolean') return false;
const leaf = field.configPath.split('.').at(-1) ?? field.configPath;
return (
leaf === 'enabled' ||
leaf.startsWith('enable') ||
leaf.endsWith('Enabled') ||
field.label.startsWith('Enable ')
);
}
function fieldTypeRank(field: ConfigSettingsField): number {
if (field.control !== 'boolean') return 2;
return isFeatureToggle(field) ? 0 : 1;
}
function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
if (category !== 0) return category;
const section =
(SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) -
(SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER);
if (section !== 0) return section;
const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName;
const aSubOrder =
a.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder =
b.subsection === undefined
? -1
: (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection;
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
if (subsectionName !== 0) return subsectionName;
const type = fieldTypeRank(a) - fieldTypeRank(b);
if (type !== 0) return type;
const pathOrder =
(PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) -
(PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER);
if (pathOrder !== 0) return pathOrder;
const label = a.label.localeCompare(b.label);
if (label !== 0) return label;
return a.configPath.localeCompare(b.configPath);
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
@@ -273,7 +644,29 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
path === 'ankiConnect.ai.enabled' ||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
path === 'ankiConnect.knownWords.highlightEnabled' ||
path === 'ankiConnect.knownWords.refreshMinutes' ||
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.matchMode' ||
path === 'ankiConnect.knownWords.decks' ||
path === 'ankiConnect.nPlusOne.enabled' ||
path === 'ankiConnect.nPlusOne.minSentenceWords' ||
path === 'ankiConnect.fields.word' ||
path === 'ankiConnect.fields.audio' ||
path === 'ankiConnect.fields.image' ||
path === 'ankiConnect.fields.sentence' ||
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'mpv.aniskipButtonKey' ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
) {
return 'hot-reload';
}
@@ -283,13 +676,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
return {
id: leaf.path,
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
description: description ?? `${humanizePath(leaf.path)} setting.`,
configPath: leaf.path,
category,
section,
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value,
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
@@ -299,6 +694,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
}
@@ -306,13 +702,7 @@ 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);
});
return leaves.map(fieldForLeaf).sort(compareFields);
}
export function getConfigSettingsCoverage(