mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
getNodeValue,
|
||||
parseTree as parseJsoncTree,
|
||||
type Node as JsoncNode,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import { DEFAULT_CONFIG } from './definitions';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
export type LegacyAnkiConnectNPlusOneMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
||||
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
|
||||
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
|
||||
matchMode: 'ankiConnect.knownWords.matchMode',
|
||||
decks: 'ankiConnect.knownWords.decks',
|
||||
knownWord: 'subtitleStyle.knownWordColor',
|
||||
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||
} as const;
|
||||
|
||||
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||
return propertyNode.children?.[0]?.value;
|
||||
}
|
||||
|
||||
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
|
||||
return propertyNode?.children?.[1];
|
||||
}
|
||||
|
||||
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||
}
|
||||
|
||||
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
|
||||
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
return matches.at(-1);
|
||||
}
|
||||
|
||||
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
|
||||
return objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
}
|
||||
|
||||
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
|
||||
let node = root;
|
||||
for (const segment of path.split('.')) {
|
||||
node = propertyValue(findLastProperty(node, segment));
|
||||
if (!node) return undefined;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function hasPath(root: JsoncNode | undefined, path: string): boolean {
|
||||
return findValueAtPath(root, path) !== undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyDecks(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||
const decks = value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const deck of new Set(decks)) {
|
||||
normalized[deck] = defaultFields;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function asLegacyColor(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const text = value.trim();
|
||||
return hexColorPattern.test(text) ? text : undefined;
|
||||
}
|
||||
|
||||
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
hasLegacy: boolean;
|
||||
} {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
|
||||
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
|
||||
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
|
||||
|
||||
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||
let hasLegacy = false;
|
||||
|
||||
for (const nPlusOne of nPlusOneObjects) {
|
||||
for (const property of objectProperties(nPlusOne)) {
|
||||
const key = propertyKey(property);
|
||||
if (!key) continue;
|
||||
const valueNode = propertyValue(property);
|
||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
||||
canonicalNPlusOneValues.set(key, value);
|
||||
continue;
|
||||
}
|
||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||
hasLegacy = true;
|
||||
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nPlusOneObjects.length > 1) {
|
||||
for (const [key, value] of canonicalNPlusOneValues) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
|
||||
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
|
||||
>) {
|
||||
if (!legacyValues.has(key)) continue;
|
||||
if (!hasPath(root, path)) {
|
||||
const value =
|
||||
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyKnownWordsColor = asLegacyColor(knownWordsColor);
|
||||
if (legacyKnownWordsColor !== undefined) {
|
||||
hasLegacy = true;
|
||||
if (!hasPath(root, 'subtitleStyle.knownWordColor')) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.knownWordColor',
|
||||
value: legacyKnownWordsColor,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: 'ankiConnect.knownWords.color',
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, hasLegacy };
|
||||
}
|
||||
|
||||
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacyAnkiConnectNPlusOneMigrationResult {
|
||||
const errors: ParseError[] = [];
|
||||
const root = parseJsoncTree(options.content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (!root || errors.length > 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
|
||||
if (operations.length === 0 && !hasLegacy) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
+250
-36
@@ -12,16 +12,44 @@ import {
|
||||
} from './definitions';
|
||||
import { parseConfigContent } from './parse';
|
||||
import { generateConfigTemplate } from './template';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
||||
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
|
||||
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
|
||||
const values: Record<string, unknown> = {
|
||||
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
|
||||
};
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return buildSubtitleCssDeclarationObject(scope, values);
|
||||
}
|
||||
|
||||
test('loads defaults when config is missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
@@ -61,7 +89,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
|
||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
|
||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||
@@ -73,8 +101,9 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||
assert.equal(config.subtitleSidebar.enabled, true);
|
||||
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
|
||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.subtitleStyle.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.lineHeight, 1.35);
|
||||
@@ -83,13 +112,19 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
|
||||
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
|
||||
assert.deepEqual(config.subtitleSidebar.css, {});
|
||||
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.immersionTracking.enabled, true);
|
||||
assert.equal(config.immersionTracking.dbPath, '');
|
||||
assert.equal(config.immersionTracking.batchSize, 25);
|
||||
@@ -113,6 +148,13 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, true);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
assert.equal(config.mpv.aniskipEnabled, true);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('parses updates config and warns on invalid values', () => {
|
||||
@@ -181,6 +223,58 @@ test('throws actionable startup parse error for malformed config at construction
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves legacy subtitle appearance options without rewriting config on load', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 42,
|
||||
"fontColor": "#ffffff",
|
||||
"hoverTokenColor": "#abcdef",
|
||||
"hoverTokenBackgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-size": "44px",
|
||||
"text-wrap": "balance"
|
||||
},
|
||||
"secondary": {
|
||||
"fontSize": 28,
|
||||
"fontColor": "#bbbbbb"
|
||||
}
|
||||
},
|
||||
"subtitleSidebar": {
|
||||
"fontFamily": "M PLUS 1, sans-serif",
|
||||
"fontSize": 18,
|
||||
"textColor": "#dddddd",
|
||||
"timestampColor": "#aaaaaa",
|
||||
"css": {
|
||||
"font-size": "19px"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'--subtitle-hover-token-color': '#abcdef',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
|
||||
color: '#bbbbbb',
|
||||
'font-size': '28px',
|
||||
});
|
||||
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
|
||||
'font-family': 'M PLUS 1, sans-serif',
|
||||
color: '#dddddd',
|
||||
'font-size': '19px',
|
||||
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
|
||||
});
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -255,6 +349,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses managed mpv plugin runtime settings from config', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "/tmp/custom-subminer.sock",
|
||||
"backend": "x11",
|
||||
"autoStartSubMiner": false,
|
||||
"pauseUntilOverlayReady": false,
|
||||
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
|
||||
"aniskipEnabled": false,
|
||||
"aniskipButtonKey": "F8"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
const config = validService.getConfig();
|
||||
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
|
||||
assert.equal(config.mpv.backend, 'x11');
|
||||
assert.equal(config.mpv.autoStartSubMiner, false);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, false);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(config.mpv.aniskipEnabled, false);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'F8');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "",
|
||||
"backend": "weston",
|
||||
"autoStartSubMiner": "yes",
|
||||
"pauseUntilOverlayReady": "no",
|
||||
"subminerBinaryPath": 42,
|
||||
"aniskipEnabled": "disabled",
|
||||
"aniskipButtonKey": ""
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
const invalidConfig = invalidService.getConfig();
|
||||
const warnings = invalidService.getWarnings();
|
||||
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
|
||||
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
|
||||
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
|
||||
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
|
||||
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
|
||||
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
|
||||
});
|
||||
|
||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -1685,6 +1843,7 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -1846,7 +2005,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
});
|
||||
|
||||
test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -1867,17 +2026,16 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
|
||||
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"nPlusOne": "#c6a0f6"
|
||||
@@ -1886,22 +2044,31 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
"color": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
||||
test('legacy migration failures are logged and rethrown', () => {
|
||||
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
|
||||
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
|
||||
|
||||
assert.ok(catchBlock);
|
||||
assert.match(catchBlock, /legacy config migration failed/);
|
||||
assert.match(catchBlock, /console\.error/);
|
||||
assert.match(catchBlock, /throw error;/);
|
||||
});
|
||||
|
||||
test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": true,
|
||||
@@ -1911,32 +2078,50 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
"knownWord": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(config.ankiConnect.knownWords.decks, {
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.decks' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.knownWord',
|
||||
),
|
||||
);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
|
||||
});
|
||||
|
||||
test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true,
|
||||
"minSentenceWords": 3
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": "3"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
@@ -1960,6 +2145,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.ok(
|
||||
@@ -2280,9 +2466,9 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||
assert.match(output, /"color": "#a6da95"/);
|
||||
assert.match(output, /"knownWordColor": "#a6da95"/);
|
||||
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||
assert.match(output, /"minSentenceWords": 3/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
assert.match(
|
||||
@@ -2385,6 +2571,34 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('template generator uses settings CSS declaration paths for appearance fields', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output);
|
||||
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.css'),
|
||||
buildDefaultSubtitleCssDeclarations('primary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
|
||||
buildDefaultSubtitleCssDeclarations('secondary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleSidebar.css'),
|
||||
buildDefaultSubtitleCssDeclarations('sidebar'),
|
||||
);
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
assert.equal(
|
||||
getValueAtPath(parsed, path),
|
||||
undefined,
|
||||
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
||||
|
||||
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
primarySubLanguages: ['ja', 'jpn'],
|
||||
},
|
||||
subsync: {
|
||||
defaultMode: 'auto',
|
||||
alass_path: '',
|
||||
ffsubsync_path: '',
|
||||
ffmpeg_path: '',
|
||||
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
mecab: true,
|
||||
yomitanExtension: true,
|
||||
subtitleDictionaries: true,
|
||||
jellyfinRemoteSession: true,
|
||||
jellyfinRemoteSession: false,
|
||||
},
|
||||
updates: {
|
||||
enabled: true,
|
||||
@@ -124,5 +123,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
addMinedWordsImmediately: true,
|
||||
matchMode: 'headword',
|
||||
decks: {},
|
||||
color: '#a6da95',
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: true,
|
||||
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
enabled: false,
|
||||
minSentenceWords: 3,
|
||||
nPlusOne: '#c6a0f6',
|
||||
},
|
||||
metadata: {
|
||||
pattern: '[SubMiner] %f (%t)',
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: 'Japanese sentences',
|
||||
sentenceCardModel: 'Lapis',
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
socketPath: getDefaultMpvSocketPath(),
|
||||
backend: 'auto',
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '',
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
anilist: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,13 +3,14 @@ import { ResolvedConfig } from '../../types/config';
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'visible',
|
||||
css: {},
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
nameMatchEnabled: true,
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
css: {},
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 24,
|
||||
fontColor: '#cad3f5',
|
||||
lineHeight: 1.35,
|
||||
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: '600',
|
||||
@@ -63,13 +69,14 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
autoOpen: false,
|
||||
layout: 'overlay',
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
pauseVideoOnHover: true,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
maxWidth: 420,
|
||||
opacity: 0.95,
|
||||
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 16,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
|
||||
@@ -63,10 +63,9 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.knownWordColor',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
'subtitleStyle.paintOrder',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
@@ -77,11 +76,14 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.paintOrder',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.WebkitTextStroke',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.WebkitTextStroke',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
@@ -103,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
@@ -112,6 +121,20 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('known-word annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('n+1 annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
@@ -339,7 +339,8 @@ export function buildCoreConfigOptionRegistry(
|
||||
path: 'auto_start_overlay',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.auto_start_overlay,
|
||||
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
|
||||
description:
|
||||
'Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner.',
|
||||
},
|
||||
{
|
||||
path: 'secondarySub.secondarySubLanguages',
|
||||
@@ -387,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.annotationWebsocket.port,
|
||||
description: 'Annotated subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.defaultMode',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'manual'],
|
||||
defaultValue: defaultConfig.subsync.defaultMode,
|
||||
description: 'Subsync default mode.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.replace',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -278,6 +278,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||
description:
|
||||
'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||
kind: 'number',
|
||||
@@ -291,18 +298,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.nPlusOne.nPlusOne',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
|
||||
description: 'Color used for the single N+1 target token highlight.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.color',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.color,
|
||||
description: 'Color used for known-word highlights.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||
kind: 'enum',
|
||||
@@ -454,6 +449,53 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.mpv.launchMode,
|
||||
description: 'Default window state for SubMiner-managed mpv launches.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.socketPath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.socketPath,
|
||||
description:
|
||||
'mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.backend',
|
||||
kind: 'enum',
|
||||
enumValues: ['auto', 'hyprland', 'sway', 'x11', 'macos', 'windows'],
|
||||
defaultValue: defaultConfig.mpv.backend,
|
||||
description:
|
||||
'Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.autoStartSubMiner',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.autoStartSubMiner,
|
||||
description: 'Start SubMiner in the background when SubMiner-managed mpv loads a file.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.pauseUntilOverlayReady',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.pauseUntilOverlayReady,
|
||||
description:
|
||||
'Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.subminerBinaryPath',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.subminerBinaryPath,
|
||||
description:
|
||||
'Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipButtonKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -567,7 +609,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
},
|
||||
{
|
||||
path: 'discordPresence.presenceStyle',
|
||||
kind: 'string',
|
||||
kind: 'enum',
|
||||
enumValues: ['default', 'meme', 'japanese', 'minimal'],
|
||||
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
||||
description:
|
||||
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
||||
|
||||
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.css,
|
||||
description:
|
||||
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleStyle.secondary.css,
|
||||
description:
|
||||
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
kind: 'boolean',
|
||||
@@ -69,6 +83,18 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.knownWordColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.knownWordColor,
|
||||
description: 'Color used for known-word subtitle highlights.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nPlusOneColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
|
||||
description: 'Color used for the single N+1 target token subtitle highlight.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
@@ -155,6 +181,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subtitleSidebar.autoScroll,
|
||||
description: 'Auto-scroll the active subtitle cue into view while playback advances.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleSidebar.css',
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.subtitleSidebar.css,
|
||||
description:
|
||||
'CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleSidebar.maxWidth',
|
||||
kind: 'number',
|
||||
|
||||
@@ -20,9 +20,9 @@ export function buildRuntimeOptionRegistry(
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.nPlusOne',
|
||||
id: 'subtitle.annotation.knownWords.highlightEnabled',
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
label: 'N+1 Annotation',
|
||||
label: 'Known Word Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.nPlusOne',
|
||||
path: 'ankiConnect.nPlusOne.enabled',
|
||||
label: 'N+1 Annotation',
|
||||
scope: 'subtitle',
|
||||
valueType: 'boolean',
|
||||
allowedValues: [true, false],
|
||||
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||
requiresRestart: false,
|
||||
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
|
||||
toAnkiPatch: (value) => ({
|
||||
nPlusOne: {
|
||||
enabled: value === true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'subtitle.annotation.jlpt',
|
||||
path: 'subtitleStyle.enableJlpt',
|
||||
|
||||
@@ -2,9 +2,10 @@ import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Overlay Auto-Start',
|
||||
title: 'Visible Overlay Auto-Start',
|
||||
description: [
|
||||
'When overlay connects to mpv, automatically show overlay and hide mpv subtitles.',
|
||||
'Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.',
|
||||
'SubMiner can still auto-start in the background when this is false.',
|
||||
],
|
||||
key: 'auto_start_overlay',
|
||||
},
|
||||
@@ -32,6 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Logging',
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
|
||||
key: 'logging',
|
||||
},
|
||||
{
|
||||
@@ -88,8 +90,9 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'secondarySub',
|
||||
},
|
||||
{
|
||||
title: 'Auto Subtitle Sync',
|
||||
title: 'Subtitle Sync',
|
||||
description: ['Subsync engine and executable paths.'],
|
||||
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
|
||||
key: 'subsync',
|
||||
},
|
||||
{
|
||||
@@ -126,7 +129,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
@@ -135,6 +138,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Jimaku',
|
||||
description: ['Jimaku API configuration and defaults.'],
|
||||
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
@@ -142,6 +146,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
description: [
|
||||
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
|
||||
],
|
||||
notes: ['Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.'],
|
||||
key: 'youtube',
|
||||
},
|
||||
{
|
||||
@@ -166,7 +171,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'MPV Launcher',
|
||||
description: [
|
||||
'Optional mpv.exe override for Windows playback entry points.',
|
||||
'SubMiner-managed mpv launch and bundled plugin options.',
|
||||
'Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.',
|
||||
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
|
||||
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||
],
|
||||
|
||||
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
|
||||
});
|
||||
|
||||
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
nPlusOne: { enabled: false },
|
||||
});
|
||||
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
|
||||
});
|
||||
|
||||
test('converts legacy knownWords.decks array to object with default fields', () => {
|
||||
const { context, warnings } = makeContext({
|
||||
knownWords: { decks: ['Core Deck'] },
|
||||
|
||||
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
|
||||
|
||||
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
|
||||
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
|
||||
if (knownWordsHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
|
||||
} else if (knownWordsConfig.highlightEnabled !== undefined) {
|
||||
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
|
||||
);
|
||||
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.highlightEnabled',
|
||||
nPlusOneConfig.highlightEnabled,
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.highlightEnabled =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
|
||||
} else {
|
||||
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
|
||||
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
|
||||
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
|
||||
const hasValidKnownWordsRefreshMinutes =
|
||||
knownWordsRefreshMinutes !== undefined &&
|
||||
Number.isInteger(knownWordsRefreshMinutes) &&
|
||||
knownWordsRefreshMinutes > 0;
|
||||
const hasValidLegacyNPlusOneRefreshMinutes =
|
||||
legacyNPlusOneRefreshMinutes !== undefined &&
|
||||
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
|
||||
legacyNPlusOneRefreshMinutes > 0;
|
||||
if (knownWordsRefreshMinutes !== undefined) {
|
||||
if (hasValidKnownWordsRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
|
||||
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
|
||||
if (hasValidLegacyNPlusOneRefreshMinutes) {
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.refreshMinutes',
|
||||
nPlusOneConfig.refreshMinutes,
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes,
|
||||
'Expected a positive integer.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.refreshMinutes =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
|
||||
}
|
||||
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
|
||||
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
|
||||
const hasValidLegacyRefreshMinutes =
|
||||
@@ -789,6 +747,23 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||
}
|
||||
|
||||
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||
if (nPlusOneEnabled !== undefined) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.enabled',
|
||||
nPlusOneConfig.enabled,
|
||||
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = true;
|
||||
} else {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
}
|
||||
|
||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||
const hasValidNPlusOneMinSentenceWords =
|
||||
nPlusOneMinSentenceWords !== undefined &&
|
||||
@@ -813,12 +788,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
|
||||
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
|
||||
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
|
||||
const hasValidKnownWordsMatchMode =
|
||||
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
|
||||
const hasValidLegacyNPlusOneMatchMode =
|
||||
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
|
||||
const hasValidLegacyMatchMode =
|
||||
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
|
||||
if (hasValidKnownWordsMatchMode) {
|
||||
@@ -832,25 +804,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
} else if (legacyNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyNPlusOneMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
|
||||
);
|
||||
} else {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.matchMode',
|
||||
nPlusOneConfig.matchMode,
|
||||
context.resolved.ankiConnect.knownWords.matchMode,
|
||||
"Expected 'headword' or 'surface'.",
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.matchMode =
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
|
||||
}
|
||||
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
|
||||
if (hasValidLegacyMatchMode) {
|
||||
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
|
||||
@@ -882,7 +835,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'Word Reading',
|
||||
];
|
||||
const knownWordsDecks = knownWordsConfig.decks;
|
||||
const legacyNPlusOneDecks = nPlusOneConfig.decks;
|
||||
if (isObject(knownWordsDecks)) {
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
|
||||
@@ -926,67 +878,31 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
context.resolved.ankiConnect.knownWords.decks,
|
||||
'Expected an object mapping deck names to field arrays.',
|
||||
);
|
||||
} else if (Array.isArray(legacyNPlusOneDecks)) {
|
||||
const normalized = legacyNPlusOneDecks
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const resolved: Record<string, string[]> = {};
|
||||
for (const deck of new Set(normalized)) {
|
||||
resolved[deck] = DEFAULT_FIELDS;
|
||||
}
|
||||
context.resolved.ankiConnect.knownWords.decks = resolved;
|
||||
if (normalized.length > 0) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.decks',
|
||||
legacyNPlusOneDecks,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
||||
if (nPlusOneHighlightColor !== undefined) {
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor;
|
||||
} else if (nPlusOneConfig.nPlusOne !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.nPlusOne',
|
||||
nPlusOneConfig.nPlusOne,
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
|
||||
}
|
||||
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
||||
? (context.src.subtitleStyle as Record<string, unknown>)
|
||||
: {};
|
||||
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||
|
||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
||||
if (knownWordsColor !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.color = knownWordsColor;
|
||||
if (!hasCanonicalKnownWordColor) {
|
||||
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
|
||||
}
|
||||
context.warn(
|
||||
'ankiConnect.knownWords.color',
|
||||
knownWordsConfig.color,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||
);
|
||||
} else if (knownWordsConfig.color !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.knownWords.color',
|
||||
knownWordsConfig.color,
|
||||
context.resolved.ankiConnect.knownWords.color,
|
||||
context.resolved.subtitleStyle.knownWordColor,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
||||
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
||||
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
DEFAULT_CONFIG.ankiConnect.knownWords.color,
|
||||
'Legacy key is deprecated; use ankiConnect.knownWords.color',
|
||||
);
|
||||
} else if (nPlusOneConfig.knownWord !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.nPlusOne.knownWord',
|
||||
nPlusOneConfig.knownWord,
|
||||
context.resolved.ankiConnect.knownWords.color,
|
||||
'Expected a hex color value.',
|
||||
);
|
||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
if (isObject(src.subsync)) {
|
||||
const mode = src.subsync.defaultMode;
|
||||
if (mode === 'auto' || mode === 'manual') {
|
||||
resolved.subsync.defaultMode = mode;
|
||||
} else if (mode !== undefined) {
|
||||
warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.');
|
||||
}
|
||||
|
||||
const alass = asString(src.subsync.alass_path);
|
||||
if (alass !== undefined) resolved.subsync.alass_path = alass;
|
||||
const ffsubsync = asString(src.subsync.ffsubsync_path);
|
||||
|
||||
@@ -253,6 +253,97 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
`Expected one of: ${MPV_LAUNCH_MODE_VALUES.map((value) => `'${value}'`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const socketPath = asString(src.mpv.socketPath);
|
||||
if (socketPath !== undefined && socketPath.trim().length > 0) {
|
||||
resolved.mpv.socketPath = socketPath.trim();
|
||||
} else if (src.mpv.socketPath !== undefined) {
|
||||
warn(
|
||||
'mpv.socketPath',
|
||||
src.mpv.socketPath,
|
||||
resolved.mpv.socketPath,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
|
||||
const backend = asString(src.mpv.backend);
|
||||
if (
|
||||
backend === 'auto' ||
|
||||
backend === 'hyprland' ||
|
||||
backend === 'sway' ||
|
||||
backend === 'x11' ||
|
||||
backend === 'macos' ||
|
||||
backend === 'windows'
|
||||
) {
|
||||
resolved.mpv.backend = backend;
|
||||
} else if (src.mpv.backend !== undefined) {
|
||||
warn(
|
||||
'mpv.backend',
|
||||
src.mpv.backend,
|
||||
resolved.mpv.backend,
|
||||
'Expected auto, hyprland, sway, x11, macos, or windows.',
|
||||
);
|
||||
}
|
||||
|
||||
const autoStartSubMiner = asBoolean(src.mpv.autoStartSubMiner);
|
||||
if (autoStartSubMiner !== undefined) {
|
||||
resolved.mpv.autoStartSubMiner = autoStartSubMiner;
|
||||
} else if (src.mpv.autoStartSubMiner !== undefined) {
|
||||
warn(
|
||||
'mpv.autoStartSubMiner',
|
||||
src.mpv.autoStartSubMiner,
|
||||
resolved.mpv.autoStartSubMiner,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const pauseUntilOverlayReady = asBoolean(src.mpv.pauseUntilOverlayReady);
|
||||
if (pauseUntilOverlayReady !== undefined) {
|
||||
resolved.mpv.pauseUntilOverlayReady = pauseUntilOverlayReady;
|
||||
} else if (src.mpv.pauseUntilOverlayReady !== undefined) {
|
||||
warn(
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
src.mpv.pauseUntilOverlayReady,
|
||||
resolved.mpv.pauseUntilOverlayReady,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const subminerBinaryPath = asString(src.mpv.subminerBinaryPath);
|
||||
if (subminerBinaryPath !== undefined) {
|
||||
resolved.mpv.subminerBinaryPath = subminerBinaryPath.trim();
|
||||
} else if (src.mpv.subminerBinaryPath !== undefined) {
|
||||
warn(
|
||||
'mpv.subminerBinaryPath',
|
||||
src.mpv.subminerBinaryPath,
|
||||
resolved.mpv.subminerBinaryPath,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const aniskipEnabled = asBoolean(src.mpv.aniskipEnabled);
|
||||
if (aniskipEnabled !== undefined) {
|
||||
resolved.mpv.aniskipEnabled = aniskipEnabled;
|
||||
} else if (src.mpv.aniskipEnabled !== undefined) {
|
||||
warn(
|
||||
'mpv.aniskipEnabled',
|
||||
src.mpv.aniskipEnabled,
|
||||
resolved.mpv.aniskipEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const aniskipButtonKey = asString(src.mpv.aniskipButtonKey);
|
||||
if (aniskipButtonKey !== undefined && aniskipButtonKey.trim().length > 0) {
|
||||
resolved.mpv.aniskipButtonKey = aniskipButtonKey.trim();
|
||||
} else if (src.mpv.aniskipButtonKey !== undefined) {
|
||||
warn(
|
||||
'mpv.aniskipButtonKey',
|
||||
src.mpv.aniskipButtonKey,
|
||||
resolved.mpv.aniskipButtonKey,
|
||||
'Expected non-empty string.',
|
||||
);
|
||||
}
|
||||
} else if (src.mpv !== undefined) {
|
||||
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
|
||||
}
|
||||
|
||||
@@ -10,6 +10,40 @@ import {
|
||||
isObject,
|
||||
} from './shared';
|
||||
|
||||
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [property, declarationValue] of Object.entries(value)) {
|
||||
if (typeof declarationValue !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
if (declarationValue.trim().length > 0) {
|
||||
declarations[property] = declarationValue.trim();
|
||||
}
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
|
||||
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
|
||||
|
||||
function applySubtitleHoverTokenCssCompatibility(
|
||||
subtitleStyle: ResolvedConfig['subtitleStyle'],
|
||||
): void {
|
||||
const hoverTokenColor = asCssColor(subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY]);
|
||||
if (hoverTokenColor !== undefined) {
|
||||
subtitleStyle.hoverTokenColor = hoverTokenColor;
|
||||
}
|
||||
|
||||
const hoverTokenBackgroundColor = asCssColor(
|
||||
subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY],
|
||||
);
|
||||
if (hoverTokenBackgroundColor !== undefined) {
|
||||
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
@@ -157,6 +191,10 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
|
||||
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
@@ -209,6 +247,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
|
||||
if (css !== undefined) {
|
||||
resolved.subtitleStyle.css = css;
|
||||
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
|
||||
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
|
||||
warn(
|
||||
'subtitleStyle.css',
|
||||
(src.subtitleStyle as { css?: unknown }).css,
|
||||
resolved.subtitleStyle.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const rawSecondary = isObject(src.subtitleStyle.secondary)
|
||||
? (src.subtitleStyle.secondary as { css?: unknown })
|
||||
: undefined;
|
||||
const secondaryCss = asCssDeclarations(rawSecondary?.css);
|
||||
if (secondaryCss !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = secondaryCss;
|
||||
} else if (rawSecondary?.css !== undefined) {
|
||||
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
|
||||
warn(
|
||||
'subtitleStyle.secondary.css',
|
||||
rawSecondary.css,
|
||||
resolved.subtitleStyle.secondary.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const preserveLineBreaks = asBoolean(
|
||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||
);
|
||||
@@ -301,6 +368,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
|
||||
|
||||
const nameMatchColor = asColor(
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
);
|
||||
@@ -333,6 +402,34 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const knownWordColor = asColor(
|
||||
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||
);
|
||||
if (knownWordColor !== undefined) {
|
||||
resolved.subtitleStyle.knownWordColor = knownWordColor;
|
||||
} else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) {
|
||||
resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor;
|
||||
warn(
|
||||
'subtitleStyle.knownWordColor',
|
||||
(src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor,
|
||||
resolved.subtitleStyle.knownWordColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const nPlusOneColor = asColor((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor);
|
||||
if (nPlusOneColor !== undefined) {
|
||||
resolved.subtitleStyle.nPlusOneColor = nPlusOneColor;
|
||||
} else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) {
|
||||
resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor;
|
||||
warn(
|
||||
'subtitleStyle.nPlusOneColor',
|
||||
(src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor,
|
||||
resolved.subtitleStyle.nPlusOneColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
@@ -445,6 +542,19 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
...(src.subtitleSidebar as ResolvedConfig['subtitleSidebar']),
|
||||
};
|
||||
|
||||
const css = asCssDeclarations((src.subtitleSidebar as { css?: unknown }).css);
|
||||
if (css !== undefined) {
|
||||
resolved.subtitleSidebar.css = css;
|
||||
} else if ((src.subtitleSidebar as { css?: unknown }).css !== undefined) {
|
||||
resolved.subtitleSidebar.css = fallback.css;
|
||||
warn(
|
||||
'subtitleSidebar.css',
|
||||
(src.subtitleSidebar as { css?: unknown }).css,
|
||||
resolved.subtitleSidebar.css,
|
||||
'Expected an object whose values are CSS declaration strings.',
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = asBoolean((src.subtitleSidebar as { enabled?: unknown }).enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.subtitleSidebar.enabled = enabled;
|
||||
|
||||
@@ -55,6 +55,33 @@ test('subtitleSidebar accepts zero opacity', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleSidebar css declarations accept string declaration maps and warn on invalid values', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
css: {
|
||||
'font-size': '18px',
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.deepEqual(valid.context.resolved.subtitleSidebar.css, {
|
||||
'font-size': '18px',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
css: {
|
||||
color: 42,
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.deepEqual(invalid.context.resolved.subtitleSidebar.css, {});
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleSidebar.css'));
|
||||
});
|
||||
|
||||
test('subtitleSidebar falls back and warns on invalid values', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleSidebar: {
|
||||
|
||||
@@ -28,6 +28,68 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
},
|
||||
secondary: {
|
||||
css: {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||
'font-size': '42px',
|
||||
'text-wrap': 'balance',
|
||||
'--subtitle-hover-token-color': '#c6a0f6',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
});
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
|
||||
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||
'text-transform': 'uppercase',
|
||||
});
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'font-size': 42,
|
||||
} as never,
|
||||
secondary: {
|
||||
css: 'font-size: 28px;' as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
|
||||
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
|
||||
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||
});
|
||||
|
||||
test('subtitleStyle hover css compatibility ignores invalid color declarations', () => {
|
||||
const { context } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
css: {
|
||||
'--subtitle-hover-token-color': 'purple',
|
||||
'--subtitle-hover-token-background-color': '#363a4fd6',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
assert.equal(context.resolved.subtitleStyle.hoverTokenBackgroundColor, '#363a4fd6');
|
||||
});
|
||||
|
||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
@@ -100,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
@@ -155,6 +217,55 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
knownWordColor: '#ed8796',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
knownWordColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.knownWordColor' &&
|
||||
warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nPlusOneColor: '#ed8796',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796');
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nPlusOneColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nPlusOneColor' && warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
+45
-3
@@ -4,6 +4,8 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
|
||||
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
|
||||
import { resolveConfig } from './resolve';
|
||||
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
|
||||
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
|
||||
|
||||
export type ReloadConfigStrictResult =
|
||||
| {
|
||||
@@ -49,7 +51,10 @@ export class ConfigService {
|
||||
if (!loadResult.ok) {
|
||||
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
|
||||
}
|
||||
this.applyResolvedConfig(loadResult.config, loadResult.path);
|
||||
this.applyResolvedConfig(
|
||||
this.migrateLegacyConfig(loadResult.config, loadResult.path),
|
||||
loadResult.path,
|
||||
);
|
||||
}
|
||||
|
||||
getConfigPath(): string {
|
||||
@@ -70,7 +75,7 @@ export class ConfigService {
|
||||
|
||||
reloadConfig(): ResolvedConfig {
|
||||
const { config, path: configPath } = loadRawConfig(this.configPaths);
|
||||
return this.applyResolvedConfig(config, configPath);
|
||||
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
|
||||
}
|
||||
|
||||
reloadConfigStrict(): ReloadConfigStrictResult {
|
||||
@@ -80,7 +85,10 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
const { config, path: configPath } = loadResult;
|
||||
const resolvedConfig = this.applyResolvedConfig(config, configPath);
|
||||
const resolvedConfig = this.applyResolvedConfig(
|
||||
this.migrateLegacyConfig(config, configPath),
|
||||
configPath,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
config: resolvedConfig,
|
||||
@@ -113,4 +121,38 @@ export class ConfigService {
|
||||
this.warnings = warnings;
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
try {
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
let rawConfig = config;
|
||||
let migrated = false;
|
||||
for (const applyMigration of [
|
||||
applyLegacyAnkiConnectNPlusOneMigrationToContent,
|
||||
applyLegacySubtitleStyleCssMigrationToContent,
|
||||
]) {
|
||||
const migration = applyMigration({
|
||||
content,
|
||||
rawConfig,
|
||||
});
|
||||
if (!migration.migrated) {
|
||||
continue;
|
||||
}
|
||||
content = migration.content;
|
||||
rawConfig = migration.rawConfig;
|
||||
migrated = true;
|
||||
}
|
||||
if (!migrated) {
|
||||
return rawConfig;
|
||||
}
|
||||
return rawConfig;
|
||||
} catch (error) {
|
||||
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
export type LegacySubtitleStyleCssMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function hasPath(root: unknown, path: string): boolean {
|
||||
let current = root;
|
||||
const segments = path.split('.');
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
||||
return false;
|
||||
}
|
||||
if (index === segments.length - 1) {
|
||||
return true;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
|
||||
if (path === 'subtitleStyle.hoverTokenColor') {
|
||||
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
|
||||
}
|
||||
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildLegacySubtitleStyleCssMigrationOperations(
|
||||
rawConfig: RawConfig,
|
||||
): ConfigSettingsPatchOperation[] {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(rawConfig, cssPath),
|
||||
};
|
||||
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
|
||||
(legacyPath) =>
|
||||
hasPath(rawConfig, legacyPath) &&
|
||||
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
|
||||
);
|
||||
if (legacyPaths.length === 0) continue;
|
||||
|
||||
for (const legacyPath of legacyPaths) {
|
||||
values[legacyPath] = getValueAtPath(rawConfig, legacyPath);
|
||||
}
|
||||
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: cssPath,
|
||||
value: buildSubtitleCssDeclarationObject(scope, values),
|
||||
});
|
||||
for (const legacyPath of legacyPaths) {
|
||||
operations.push({ op: 'reset', path: legacyPath });
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
export function applyLegacySubtitleStyleCssMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacySubtitleStyleCssMigrationResult {
|
||||
const operations = buildLegacySubtitleStyleCssMigrationOperations(options.rawConfig);
|
||||
if (operations.length === 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,18 @@ import {
|
||||
DEFAULT_KEYBINDINGS,
|
||||
deepCloneConfig,
|
||||
} from './definitions';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
||||
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||
);
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function normalizeCommentText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
|
||||
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replace(/^--/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.toLowerCase();
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
|
||||
return description;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setValueAtPath(root: unknown, path: string, value: unknown): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
current[segment] = value;
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function deleteValueAtPath(root: unknown, path: string): void {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
let current = root;
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
if (!isRecord(current)) return;
|
||||
if (index === segments.length - 1) {
|
||||
delete current[segment];
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
const cssPath = getSubtitleCssPath(scope);
|
||||
const values: Record<string, unknown> = {
|
||||
[cssPath]: getValueAtPath(templateConfig, cssPath),
|
||||
};
|
||||
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||
for (const managedPath of managedPaths) {
|
||||
values[managedPath] = getValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
|
||||
for (const managedPath of managedPaths) {
|
||||
deleteValueAtPath(templateConfig, managedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderValue(value: unknown, indent = 0, path = ''): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
const nextPad = ' '.repeat(indent + 2);
|
||||
@@ -106,6 +171,7 @@ function renderSection(
|
||||
|
||||
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
const templateConfig = deepCloneConfig(config);
|
||||
foldSubtitleCssManagedDefaults(templateConfig);
|
||||
if (templateConfig.keybindings.length === 0) {
|
||||
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
||||
key: binding.key,
|
||||
|
||||
Reference in New Issue
Block a user