mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user