mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -7,22 +7,22 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self';"
|
||||
/>
|
||||
<title>SubMiner Configuration</title>
|
||||
<title>SubMiner Settings</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="settings-shell">
|
||||
<aside class="settings-nav" aria-label="Configuration categories">
|
||||
<aside class="settings-nav" aria-label="Settings categories">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Configuration</div>
|
||||
<div class="brand-subtitle">Settings</div>
|
||||
</div>
|
||||
<nav id="categoryNav" class="category-nav"></nav>
|
||||
</aside>
|
||||
<section class="settings-main">
|
||||
<header class="settings-toolbar">
|
||||
<div class="toolbar-title-block">
|
||||
<h1 id="categoryTitle">Configuration</h1>
|
||||
<h1 id="categoryTitle">Settings</h1>
|
||||
<div id="categoryMeta" class="toolbar-meta"></div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
@@ -33,7 +33,6 @@
|
||||
placeholder="Search"
|
||||
aria-label="Search settings"
|
||||
/>
|
||||
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
|
||||
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Keybinding } from '../types/runtime';
|
||||
import {
|
||||
buildMpvKeybindingConfigValue,
|
||||
createMpvKeybindingRows,
|
||||
keyboardEventToConfigKey,
|
||||
} from './key-input';
|
||||
|
||||
test('keyboardEventToConfigKey formats Electron accelerators from learned input', () => {
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'KeyS', key: 's', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
|
||||
'accelerator',
|
||||
),
|
||||
'CommandOrControl+Shift+S',
|
||||
);
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'Slash', key: '/', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false },
|
||||
'accelerator',
|
||||
),
|
||||
'Alt+Slash',
|
||||
);
|
||||
});
|
||||
|
||||
test('keyboardEventToConfigKey formats DOM code bindings from learned input', () => {
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'KeyJ', key: 'j', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
|
||||
'dom-code',
|
||||
),
|
||||
'Ctrl+Shift+KeyJ',
|
||||
);
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{
|
||||
code: 'Backquote',
|
||||
key: '`',
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
metaKey: false,
|
||||
},
|
||||
'dom-code',
|
||||
),
|
||||
'Backquote',
|
||||
);
|
||||
});
|
||||
|
||||
test('keyboardEventToConfigKey formats bare key-code fields without modifiers', () => {
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'KeyW', key: 'w', ctrlKey: true, altKey: true, shiftKey: false, metaKey: false },
|
||||
'code',
|
||||
),
|
||||
'KeyW',
|
||||
);
|
||||
});
|
||||
|
||||
test('keyboardEventToConfigKey formats mpv key bindings from learned input', () => {
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'Tab', key: 'Tab', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false },
|
||||
'mpv-key',
|
||||
),
|
||||
'TAB',
|
||||
);
|
||||
assert.equal(
|
||||
keyboardEventToConfigKey(
|
||||
{ code: 'KeyK', key: 'K', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
|
||||
'mpv-key',
|
||||
),
|
||||
'Ctrl+Shift+K',
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
|
||||
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
|
||||
const rows = createMpvKeybindingRows(defaults, []);
|
||||
rows[0]!.key = 'KeyP';
|
||||
|
||||
assert.deepEqual(buildMpvKeybindingConfigValue(defaults, rows), [
|
||||
{ key: 'Space', command: null },
|
||||
{ key: 'KeyP', command: ['cycle', 'pause'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('MPV keybinding rows reopen moved default bindings as their default row', () => {
|
||||
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
|
||||
|
||||
assert.deepEqual(
|
||||
createMpvKeybindingRows(defaults, [
|
||||
{ key: 'Space', command: null },
|
||||
{ key: 'KeyP', command: ['cycle', 'pause'] },
|
||||
]),
|
||||
[
|
||||
{
|
||||
defaultKey: 'Space',
|
||||
key: 'KeyP',
|
||||
command: ['cycle', 'pause'],
|
||||
commandText: '["cycle","pause"]',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('MPV keybinding rows omit unchanged default bindings from config value', () => {
|
||||
const defaults: Keybinding[] = [
|
||||
{ key: 'Space', command: ['cycle', 'pause'] },
|
||||
{ key: 'KeyF', command: ['cycle', 'fullscreen'] },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
buildMpvKeybindingConfigValue(defaults, createMpvKeybindingRows(defaults, [])),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { Keybinding } from '../types/runtime';
|
||||
|
||||
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code' | 'mpv-key';
|
||||
|
||||
export interface KeyboardInputLike {
|
||||
code: string;
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export interface MpvKeybindingRow {
|
||||
defaultKey: string;
|
||||
key: string;
|
||||
command: (string | number)[] | null;
|
||||
commandText: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
]);
|
||||
|
||||
const ELECTRON_KEY_BY_CODE: Record<string, string> = {
|
||||
Backquote: 'Backquote',
|
||||
Backslash: 'Backslash',
|
||||
BracketLeft: 'BracketLeft',
|
||||
BracketRight: 'BracketRight',
|
||||
Comma: 'Comma',
|
||||
Delete: 'Delete',
|
||||
End: 'End',
|
||||
Enter: 'Enter',
|
||||
Equal: 'Plus',
|
||||
Escape: 'Escape',
|
||||
Home: 'Home',
|
||||
Insert: 'Insert',
|
||||
Minus: 'Minus',
|
||||
PageDown: 'PageDown',
|
||||
PageUp: 'PageUp',
|
||||
Period: 'Period',
|
||||
Quote: 'Quote',
|
||||
Semicolon: 'Semicolon',
|
||||
Slash: 'Slash',
|
||||
Space: 'Space',
|
||||
Tab: 'Tab',
|
||||
};
|
||||
|
||||
const MPV_KEY_BY_CODE: Record<string, string> = {
|
||||
Backspace: 'BS',
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Comma: ',',
|
||||
Delete: 'DEL',
|
||||
End: 'END',
|
||||
Enter: 'ENTER',
|
||||
Equal: '=',
|
||||
Escape: 'ESC',
|
||||
Home: 'HOME',
|
||||
Insert: 'INS',
|
||||
Minus: '-',
|
||||
PageDown: 'PGDWN',
|
||||
PageUp: 'PGUP',
|
||||
Period: '.',
|
||||
Quote: "'",
|
||||
Semicolon: ';',
|
||||
Slash: '/',
|
||||
Space: 'SPACE',
|
||||
Tab: 'TAB',
|
||||
};
|
||||
|
||||
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function normalizeUserBindings(userBindings: unknown): Keybinding[] {
|
||||
if (!Array.isArray(userBindings)) return [];
|
||||
return userBindings.filter((binding): binding is Keybinding => {
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
const candidate = binding as Keybinding;
|
||||
return (
|
||||
typeof candidate.key === 'string' &&
|
||||
(candidate.command === null || Array.isArray(candidate.command))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function electronKeyToken(input: KeyboardInputLike): string | null {
|
||||
if (/^Key[A-Z]$/.test(input.code)) return input.code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5);
|
||||
if (/^Numpad[0-9]$/.test(input.code)) return `num${input.code.slice(6)}`;
|
||||
if (/^F\d{1,2}$/.test(input.code)) return input.code;
|
||||
if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '');
|
||||
return ELECTRON_KEY_BY_CODE[input.code] ?? null;
|
||||
}
|
||||
|
||||
function mpvKeyToken(input: KeyboardInputLike): string | null {
|
||||
if (/^Key[A-Z]$/.test(input.code)) {
|
||||
return input.key.length === 1 ? input.key : input.code.slice(3).toLowerCase();
|
||||
}
|
||||
if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5);
|
||||
if (/^Numpad[0-9]$/.test(input.code)) return `KP${input.code.slice(6)}`;
|
||||
if (/^F\d{1,2}$/.test(input.code)) return input.code;
|
||||
if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '').toUpperCase();
|
||||
return MPV_KEY_BY_CODE[input.code] ?? null;
|
||||
}
|
||||
|
||||
export function keyboardEventToConfigKey(
|
||||
input: KeyboardInputLike,
|
||||
mode: KeyInputMode,
|
||||
): string | null {
|
||||
if (!input.code || MODIFIER_CODES.has(input.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode === 'code') {
|
||||
return input.code;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (mode === 'mpv-key') {
|
||||
if (input.ctrlKey) parts.push('Ctrl');
|
||||
if (input.altKey) parts.push('Alt');
|
||||
if (input.shiftKey) parts.push('Shift');
|
||||
if (input.metaKey) parts.push('Meta');
|
||||
const key = mpvKeyToken(input);
|
||||
return key ? [...parts, key].join('+') : null;
|
||||
}
|
||||
|
||||
if (mode === 'accelerator') {
|
||||
if (input.ctrlKey || input.metaKey) parts.push('CommandOrControl');
|
||||
if (input.altKey) parts.push('Alt');
|
||||
if (input.shiftKey) parts.push('Shift');
|
||||
const key = electronKeyToken(input);
|
||||
return key ? [...parts, key].join('+') : null;
|
||||
}
|
||||
|
||||
if (input.ctrlKey) parts.push('Ctrl');
|
||||
if (input.altKey) parts.push('Alt');
|
||||
if (input.shiftKey) parts.push('Shift');
|
||||
if (input.metaKey) parts.push('Meta');
|
||||
return [...parts, input.code].join('+');
|
||||
}
|
||||
|
||||
export function createMpvKeybindingRows(
|
||||
defaultBindings: Keybinding[],
|
||||
userBindings: unknown,
|
||||
): MpvKeybindingRow[] {
|
||||
const normalizedUserBindings = normalizeUserBindings(userBindings);
|
||||
const userByKey = new Map(normalizedUserBindings.map((binding) => [binding.key, binding]));
|
||||
const consumedUserKeys = new Set<string>();
|
||||
|
||||
const rows = defaultBindings.map((binding) => {
|
||||
const override = userByKey.get(binding.key);
|
||||
if (override?.command === null) {
|
||||
const movedOverride = normalizedUserBindings.find(
|
||||
(candidate) =>
|
||||
candidate.key !== binding.key && commandEquals(candidate.command, binding.command),
|
||||
);
|
||||
if (movedOverride) {
|
||||
consumedUserKeys.add(binding.key);
|
||||
consumedUserKeys.add(movedOverride.key);
|
||||
return {
|
||||
defaultKey: binding.key,
|
||||
key: movedOverride.key,
|
||||
command: movedOverride.command,
|
||||
commandText: JSON.stringify(movedOverride.command ?? null),
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (override) {
|
||||
consumedUserKeys.add(binding.key);
|
||||
}
|
||||
const command = override?.command ?? binding.command;
|
||||
return {
|
||||
defaultKey: binding.key,
|
||||
key: binding.key,
|
||||
command,
|
||||
commandText: JSON.stringify(command ?? null),
|
||||
isDefault: true,
|
||||
};
|
||||
});
|
||||
|
||||
for (const binding of normalizedUserBindings) {
|
||||
if (consumedUserKeys.has(binding.key)) {
|
||||
continue;
|
||||
}
|
||||
if (defaultBindings.some((defaultBinding) => defaultBinding.key === binding.key)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
defaultKey: binding.key,
|
||||
key: binding.key,
|
||||
command: binding.command,
|
||||
commandText: JSON.stringify(binding.command ?? null),
|
||||
isDefault: false,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseMpvCommandText(value: string): Keybinding['command'] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed === null) return null;
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((entry) => typeof entry === 'string' || typeof entry === 'number')
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildMpvKeybindingConfigValue(
|
||||
defaultBindings: Keybinding[],
|
||||
rows: MpvKeybindingRow[],
|
||||
): Keybinding[] {
|
||||
const next: Keybinding[] = [];
|
||||
|
||||
for (const defaultBinding of defaultBindings) {
|
||||
const row = rows.find((candidate) => candidate.defaultKey === defaultBinding.key);
|
||||
if (!row) {
|
||||
next.push({ key: defaultBinding.key, command: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.key !== defaultBinding.key) {
|
||||
next.push({ key: defaultBinding.key, command: null });
|
||||
if (row.command !== null) {
|
||||
next.push({ key: row.key, command: row.command });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!commandEquals(row.command, defaultBinding.command)) {
|
||||
next.push({ key: row.key, command: row.command });
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows.filter((candidate) => !candidate.isDefault)) {
|
||||
next.push({ key: row.key, command: row.command });
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as ankiControls from './settings-anki-controls';
|
||||
|
||||
test('note field model preference keeps configured sentence-card model before Kiku fallback', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||
'Lapis Morph',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference keeps configured sentence-card model case-insensitively', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
|
||||
'Lapis Morph',
|
||||
);
|
||||
});
|
||||
|
||||
test('note field model preference prefers exact Lapis when Kiku is unavailable', () => {
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis'], ''), 'Lapis');
|
||||
});
|
||||
|
||||
test('note field model preference prefers exact Kiku over exact Lapis', () => {
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Lapis', 'Kiku'], ''), 'Kiku');
|
||||
});
|
||||
|
||||
test('note field model preference does not treat partial Kiku matches as Kiku', () => {
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Mining'], ''), '');
|
||||
});
|
||||
|
||||
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
|
||||
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''), '');
|
||||
});
|
||||
|
||||
test('note field model preference stays blank when no current Kiku or Lapis note type exists', () => {
|
||||
assert.equal(
|
||||
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('known word deck rename selection keeps current deck on collision', () => {
|
||||
assert.equal(
|
||||
ankiControls.chooseKnownWordsDeckRenameValue(
|
||||
{ Mining: ['Word'], Core: ['Expression'] },
|
||||
'Core',
|
||||
'Mining',
|
||||
),
|
||||
'Core',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
import type { SettingsControlContext } from './settings-control-context';
|
||||
import { addOption, createElement, uniqueSorted } from './settings-control-dom';
|
||||
|
||||
const state: {
|
||||
deckNames: string[] | null;
|
||||
deckNamesLoading: boolean;
|
||||
deckNamesError: string | null;
|
||||
deckFieldNames: Map<string, string[]>;
|
||||
deckFieldNamesLoading: Set<string>;
|
||||
deckFieldNamesErrors: Map<string, string>;
|
||||
deckModelNames: Map<string, string[]>;
|
||||
deckModelNamesLoading: Map<string, Promise<string[]>>;
|
||||
modelNames: string[] | null;
|
||||
modelNamesLoading: boolean;
|
||||
modelNamesError: string | null;
|
||||
modelFieldNames: Map<string, string[]>;
|
||||
modelFieldNamesLoading: Set<string>;
|
||||
modelFieldNamesErrors: Map<string, string>;
|
||||
noteFieldModelName: string;
|
||||
ankiConnectUrl: string;
|
||||
noteFieldModelNameManuallySelected: boolean;
|
||||
} = {
|
||||
deckNames: null,
|
||||
deckNamesLoading: false,
|
||||
deckNamesError: null,
|
||||
deckFieldNames: new Map(),
|
||||
deckFieldNamesLoading: new Set(),
|
||||
deckFieldNamesErrors: new Map(),
|
||||
deckModelNames: new Map(),
|
||||
deckModelNamesLoading: new Map(),
|
||||
modelNames: null,
|
||||
modelNamesLoading: false,
|
||||
modelNamesError: null,
|
||||
modelFieldNames: new Map(),
|
||||
modelFieldNamesLoading: new Set(),
|
||||
modelFieldNamesErrors: new Map(),
|
||||
noteFieldModelName: '',
|
||||
ankiConnectUrl: '',
|
||||
noteFieldModelNameManuallySelected: false,
|
||||
};
|
||||
|
||||
let requestRender = (): void => undefined;
|
||||
|
||||
export function configureAnkiControls(options: { requestRender: () => void }): void {
|
||||
requestRender = options.requestRender;
|
||||
}
|
||||
|
||||
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||
state.noteFieldModelName = '';
|
||||
state.noteFieldModelNameManuallySelected = false;
|
||||
}
|
||||
|
||||
export function selectPreferredNoteFieldModelName(
|
||||
modelNames: readonly string[],
|
||||
currentModelName = '',
|
||||
): string {
|
||||
const normalizedCurrent = currentModelName.trim().toLowerCase();
|
||||
if (normalizedCurrent) {
|
||||
const current = modelNames.find((name) => name.trim().toLowerCase() === normalizedCurrent);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
|
||||
if (exactKiku) {
|
||||
return exactKiku;
|
||||
}
|
||||
|
||||
const exactLapis = modelNames.find((name) => name.trim().toLowerCase() === 'lapis');
|
||||
if (exactLapis) {
|
||||
return exactLapis;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function chooseKnownWordsDeckRenameValue(
|
||||
decks: Record<string, string[]>,
|
||||
currentDeckName: string,
|
||||
nextDeckName: string,
|
||||
): string {
|
||||
if (
|
||||
nextDeckName !== currentDeckName &&
|
||||
Object.prototype.hasOwnProperty.call(decks, nextDeckName)
|
||||
) {
|
||||
return currentDeckName;
|
||||
}
|
||||
return nextDeckName;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeKnownWordsDecks(value: unknown): Record<string, string[]> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const decks: Record<string, string[]> = {};
|
||||
for (const [deckName, fields] of Object.entries(value)) {
|
||||
if (!deckName) continue;
|
||||
decks[deckName] = normalizeStringArray(fields);
|
||||
}
|
||||
return decks;
|
||||
}
|
||||
|
||||
function setKnownWordsDecks(
|
||||
context: SettingsControlContext,
|
||||
path: string,
|
||||
decks: Record<string, string[]>,
|
||||
): void {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const [deckName, fields] of Object.entries(decks)) {
|
||||
if (!deckName) continue;
|
||||
next[deckName] = uniqueSorted(fields);
|
||||
}
|
||||
context.updateDraft(path, next);
|
||||
}
|
||||
|
||||
function getDraftAnkiConnectUrl(context: SettingsControlContext): string | undefined {
|
||||
const value = context.valueForPath('ankiConnect.url');
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getDraftAnkiDeckName(context: SettingsControlContext): string {
|
||||
const value = context.valueForPath('ankiConnect.deck');
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function syncAnkiConnectUrl(draftUrl: string | undefined): void {
|
||||
const nextUrl = draftUrl ?? '';
|
||||
if (state.ankiConnectUrl === nextUrl) {
|
||||
return;
|
||||
}
|
||||
const hasAnkiMetadata =
|
||||
state.deckNames !== null ||
|
||||
state.deckNamesLoading ||
|
||||
state.deckFieldNames.size > 0 ||
|
||||
state.deckFieldNamesLoading.size > 0 ||
|
||||
state.deckModelNames.size > 0 ||
|
||||
state.deckModelNamesLoading.size > 0 ||
|
||||
state.modelNames !== null ||
|
||||
state.modelNamesLoading ||
|
||||
state.modelFieldNames.size > 0 ||
|
||||
state.modelFieldNamesLoading.size > 0;
|
||||
state.ankiConnectUrl = nextUrl;
|
||||
if (hasAnkiMetadata) {
|
||||
state.noteFieldModelName = '';
|
||||
state.noteFieldModelNameManuallySelected = false;
|
||||
}
|
||||
state.deckNames = null;
|
||||
state.deckNamesLoading = false;
|
||||
state.deckNamesError = null;
|
||||
state.deckFieldNames.clear();
|
||||
state.deckFieldNamesLoading.clear();
|
||||
state.deckFieldNamesErrors.clear();
|
||||
state.deckModelNames.clear();
|
||||
state.deckModelNamesLoading.clear();
|
||||
state.modelNames = null;
|
||||
state.modelNamesLoading = false;
|
||||
state.modelNamesError = null;
|
||||
state.modelFieldNames.clear();
|
||||
state.modelFieldNamesLoading.clear();
|
||||
state.modelFieldNamesErrors.clear();
|
||||
}
|
||||
|
||||
async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (state.deckNames || state.deckNamesLoading) return;
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.deckNamesLoading = true;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.deckNames = uniqueSorted(result.values);
|
||||
state.deckNamesError = null;
|
||||
} else {
|
||||
state.deckNames = [];
|
||||
state.deckNamesError = result.error ?? 'Failed to load Anki decks.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.deckNames = [];
|
||||
state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.';
|
||||
} finally {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckNamesLoading = false;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (
|
||||
!deckName ||
|
||||
state.deckFieldNames.has(deckName) ||
|
||||
state.deckFieldNamesLoading.has(deckName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.deckFieldNamesLoading.add(deckName);
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.deckFieldNames.set(deckName, uniqueSorted(result.values));
|
||||
state.deckFieldNamesErrors.delete(deckName);
|
||||
} else {
|
||||
state.deckFieldNames.set(deckName, []);
|
||||
state.deckFieldNamesErrors.set(
|
||||
deckName,
|
||||
result.error ?? `Failed to load fields for ${deckName}.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.deckFieldNames.set(deckName, []);
|
||||
state.deckFieldNamesErrors.set(
|
||||
deckName,
|
||||
error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`,
|
||||
);
|
||||
} finally {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckFieldNamesLoading.delete(deckName);
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiDeckModelNames(deckName: string, draftUrl?: string): Promise<string[]> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (!deckName) return [];
|
||||
const cached = state.deckModelNames.get(deckName);
|
||||
if (cached) return cached;
|
||||
const loading = state.deckModelNamesLoading.get(deckName);
|
||||
if (loading) return loading;
|
||||
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
const request = window.configSettingsAPI
|
||||
.getAnkiDeckModelNames(deckName, draftUrl)
|
||||
.then((result) => {
|
||||
if (state.ankiConnectUrl !== requestUrl) return [];
|
||||
const values = result.ok ? uniqueSorted(result.values) : [];
|
||||
state.deckModelNames.set(deckName, values);
|
||||
return values;
|
||||
})
|
||||
.catch(() => {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckModelNames.set(deckName, []);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.deckModelNamesLoading.delete(deckName);
|
||||
requestRender();
|
||||
}
|
||||
});
|
||||
|
||||
state.deckModelNamesLoading.set(deckName, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function findModelName(modelNames: readonly string[], modelName: string): string {
|
||||
const normalizedModelName = modelName.trim().toLowerCase();
|
||||
return normalizedModelName
|
||||
? (modelNames.find((name) => name.toLowerCase() === normalizedModelName) ?? '')
|
||||
: '';
|
||||
}
|
||||
|
||||
async function updatePreferredNoteFieldModelName(
|
||||
modelNames: readonly string[],
|
||||
deckName: string,
|
||||
draftUrl: string | undefined,
|
||||
requestUrl: string,
|
||||
): Promise<void> {
|
||||
const deckModelNames = await loadAnkiDeckModelNames(deckName, draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl || state.noteFieldModelNameManuallySelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextModelName = '';
|
||||
for (const deckModelName of deckModelNames) {
|
||||
nextModelName = findModelName(modelNames, deckModelName);
|
||||
if (nextModelName) break;
|
||||
}
|
||||
nextModelName ||= selectPreferredNoteFieldModelName(modelNames, state.noteFieldModelName);
|
||||
|
||||
if (state.noteFieldModelName !== nextModelName) {
|
||||
state.noteFieldModelName = nextModelName;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiModelNames(draftUrl?: string, deckName = ''): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (state.modelNames) {
|
||||
if (!state.noteFieldModelNameManuallySelected) {
|
||||
void updatePreferredNoteFieldModelName(
|
||||
state.modelNames,
|
||||
deckName,
|
||||
draftUrl,
|
||||
state.ankiConnectUrl,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.modelNamesLoading) return;
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.modelNamesLoading = true;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.modelNames = uniqueSorted(result.values);
|
||||
state.modelNamesError = null;
|
||||
if (!state.noteFieldModelNameManuallySelected) {
|
||||
await updatePreferredNoteFieldModelName(state.modelNames, deckName, draftUrl, requestUrl);
|
||||
}
|
||||
} else {
|
||||
state.modelNames = [];
|
||||
state.modelNamesError = result.error ?? 'Failed to load Anki note types.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.modelNames = [];
|
||||
state.modelNamesError =
|
||||
error instanceof Error ? error.message : 'Failed to load Anki note types.';
|
||||
} finally {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.modelNamesLoading = false;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Promise<void> {
|
||||
syncAnkiConnectUrl(draftUrl);
|
||||
if (
|
||||
!modelName ||
|
||||
state.modelFieldNames.has(modelName) ||
|
||||
state.modelFieldNamesLoading.has(modelName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestUrl = state.ankiConnectUrl;
|
||||
state.modelFieldNamesLoading.add(modelName);
|
||||
try {
|
||||
const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl);
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
if (result.ok) {
|
||||
state.modelFieldNames.set(modelName, uniqueSorted(result.values));
|
||||
state.modelFieldNamesErrors.delete(modelName);
|
||||
} else {
|
||||
state.modelFieldNames.set(modelName, []);
|
||||
state.modelFieldNamesErrors.set(
|
||||
modelName,
|
||||
result.error ?? `Failed to load fields for ${modelName}.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.ankiConnectUrl !== requestUrl) return;
|
||||
state.modelFieldNames.set(modelName, []);
|
||||
state.modelFieldNamesErrors.set(
|
||||
modelName,
|
||||
error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`,
|
||||
);
|
||||
} finally {
|
||||
if (state.ankiConnectUrl === requestUrl) {
|
||||
state.modelFieldNamesLoading.delete(modelName);
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderAnkiNoteTypeInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const draftUrl = getDraftAnkiConnectUrl(context);
|
||||
void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context));
|
||||
const currentValue = context.valueForField(field);
|
||||
const current = typeof currentValue === 'string' ? currentValue : '';
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
const modelNames = uniqueSorted([...(state.modelNames ?? []), current]);
|
||||
if (state.modelNamesLoading && modelNames.length === 0) {
|
||||
addOption(select, current, 'Loading Note Types...');
|
||||
}
|
||||
for (const modelName of modelNames) {
|
||||
addOption(select, modelName);
|
||||
}
|
||||
select.value = current;
|
||||
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
|
||||
|
||||
const wrap = createElement('div', 'stacked-control');
|
||||
wrap.append(select);
|
||||
if (state.modelNamesError) {
|
||||
const hint = createElement('div', 'control-hint error');
|
||||
hint.textContent = state.modelNamesError;
|
||||
wrap.append(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderAnkiFieldInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const draftUrl = getDraftAnkiConnectUrl(context);
|
||||
void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context));
|
||||
if (state.noteFieldModelName) {
|
||||
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
|
||||
}
|
||||
|
||||
const currentValue = context.valueForField(field);
|
||||
const current = typeof currentValue === 'string' ? currentValue : '';
|
||||
const availableFields = state.noteFieldModelName
|
||||
? (state.modelFieldNames.get(state.noteFieldModelName) ?? [])
|
||||
: [];
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
if (!state.noteFieldModelName) {
|
||||
addOption(select, current, 'Select Note Type First');
|
||||
select.disabled = true;
|
||||
} else if (state.modelFieldNamesLoading.has(state.noteFieldModelName)) {
|
||||
addOption(select, current, current || 'Loading Fields...');
|
||||
select.disabled = true;
|
||||
} else {
|
||||
for (const fieldName of uniqueSorted([...availableFields, current])) {
|
||||
addOption(select, fieldName);
|
||||
}
|
||||
}
|
||||
select.value = current;
|
||||
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
|
||||
|
||||
const wrap = createElement('div', 'stacked-control');
|
||||
wrap.append(select);
|
||||
const error = state.modelFieldNamesErrors.get(state.noteFieldModelName);
|
||||
if (error) {
|
||||
const hint = createElement('div', 'control-hint error');
|
||||
hint.textContent = error;
|
||||
wrap.append(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderNoteFieldModelPicker(context: SettingsControlContext): HTMLElement {
|
||||
const draftUrl = getDraftAnkiConnectUrl(context);
|
||||
void loadAnkiModelNames(draftUrl, getDraftAnkiDeckName(context));
|
||||
if (state.noteFieldModelName) {
|
||||
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
|
||||
}
|
||||
|
||||
const row = createElement('article', 'field-row helper-row');
|
||||
const copy = createElement('div', 'field-copy');
|
||||
const title = createElement('h3');
|
||||
title.textContent = 'Note Type';
|
||||
const description = createElement('p');
|
||||
description.textContent =
|
||||
'Choose a note type from AnkiConnect to populate the field dropdowns below.';
|
||||
copy.append(title, description);
|
||||
|
||||
const control = createElement('div', 'field-control');
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
const modelNames = state.modelNames ?? [];
|
||||
if (state.modelNamesLoading && modelNames.length === 0) {
|
||||
addOption(select, '', 'Loading Note Types...');
|
||||
} else {
|
||||
for (const modelName of modelNames) {
|
||||
addOption(select, modelName);
|
||||
}
|
||||
}
|
||||
select.value = state.noteFieldModelName;
|
||||
select.addEventListener('change', () => {
|
||||
state.noteFieldModelName = select.value;
|
||||
state.noteFieldModelNameManuallySelected = true;
|
||||
requestRender();
|
||||
});
|
||||
control.append(select);
|
||||
row.append(copy, control);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function renderKnownWordsDecksInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const draftUrl = getDraftAnkiConnectUrl(context);
|
||||
void loadAnkiDeckNames(draftUrl);
|
||||
const currentDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
const deckNames = state.deckNames ?? [];
|
||||
const container = createElement('div', 'deck-field-editor');
|
||||
|
||||
const entries = Object.entries(currentDecks).sort(([left], [right]) => left.localeCompare(right));
|
||||
if (entries.length === 0) {
|
||||
const empty = createElement('div', 'control-hint');
|
||||
empty.textContent = state.deckNamesLoading
|
||||
? 'Loading Anki decks...'
|
||||
: 'No known-word decks configured.';
|
||||
container.append(empty);
|
||||
}
|
||||
|
||||
for (const [deckName, selectedFields] of entries) {
|
||||
if (deckName) {
|
||||
void loadAnkiDeckFieldNames(deckName, draftUrl);
|
||||
}
|
||||
const row = createElement('div', 'deck-field-row');
|
||||
const header = createElement('div', 'deck-field-row-header');
|
||||
const usedDeckNames = new Set(Object.keys(currentDecks));
|
||||
const deckSelect = createElement(
|
||||
'select',
|
||||
'config-input deck-field-row-name',
|
||||
) as HTMLSelectElement;
|
||||
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
|
||||
if (candidateDeck !== deckName && usedDeckNames.has(candidateDeck)) continue;
|
||||
addOption(deckSelect, candidateDeck);
|
||||
}
|
||||
deckSelect.value = deckName;
|
||||
deckSelect.addEventListener('change', () => {
|
||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
const nextDeckName = chooseKnownWordsDeckRenameValue(nextDecks, deckName, deckSelect.value);
|
||||
if (nextDeckName !== deckSelect.value) {
|
||||
deckSelect.value = nextDeckName;
|
||||
return;
|
||||
}
|
||||
const fields = nextDecks[deckName] ?? [];
|
||||
delete nextDecks[deckName];
|
||||
nextDecks[nextDeckName] = fields;
|
||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||
requestRender();
|
||||
});
|
||||
|
||||
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
|
||||
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
|
||||
const fieldActions = createElement('div', 'deck-field-actions');
|
||||
const checkboxList = createElement('div', 'field-checkbox-list');
|
||||
|
||||
const setSelectedFields = (fields: string[]): void => {
|
||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
nextDecks[deckName] = fields;
|
||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||
};
|
||||
|
||||
const selectAllButton = createElement(
|
||||
'button',
|
||||
'secondary-button compact-button',
|
||||
) as HTMLButtonElement;
|
||||
selectAllButton.type = 'button';
|
||||
selectAllButton.textContent = 'Select All';
|
||||
selectAllButton.disabled = fieldNames.length === 0;
|
||||
selectAllButton.addEventListener('click', () => {
|
||||
setSelectedFields(fieldNames);
|
||||
requestRender();
|
||||
});
|
||||
|
||||
const clearButton = createElement(
|
||||
'button',
|
||||
'secondary-button compact-button',
|
||||
) as HTMLButtonElement;
|
||||
clearButton.type = 'button';
|
||||
clearButton.textContent = 'Clear';
|
||||
clearButton.disabled = selectedFields.length === 0;
|
||||
clearButton.addEventListener('click', () => {
|
||||
setSelectedFields([]);
|
||||
requestRender();
|
||||
});
|
||||
|
||||
fieldActions.append(selectAllButton, clearButton);
|
||||
|
||||
if (state.deckFieldNamesLoading.has(deckName)) {
|
||||
const hint = createElement('div', 'control-hint');
|
||||
hint.textContent = 'Loading Fields...';
|
||||
checkboxList.append(hint);
|
||||
} else if (fieldNames.length === 0) {
|
||||
const hint = createElement('div', 'control-hint');
|
||||
hint.textContent = deckName ? 'No fields found for this deck.' : 'Select A Deck First.';
|
||||
checkboxList.append(hint);
|
||||
}
|
||||
|
||||
for (const candidateField of fieldNames) {
|
||||
const label = createElement('label', 'field-checkbox-row');
|
||||
const checkbox = createElement('input') as HTMLInputElement;
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = candidateField;
|
||||
checkbox.checked = selectedFields.includes(candidateField);
|
||||
checkbox.addEventListener('change', () => {
|
||||
const checkedFields = [
|
||||
...checkboxList.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked'),
|
||||
].map((input) => input.value);
|
||||
setSelectedFields(checkedFields);
|
||||
});
|
||||
const text = createElement('span');
|
||||
text.textContent = candidateField;
|
||||
label.append(checkbox, text);
|
||||
checkboxList.append(label);
|
||||
}
|
||||
|
||||
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Remove';
|
||||
removeButton.addEventListener('click', () => {
|
||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
delete nextDecks[deckName];
|
||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||
requestRender();
|
||||
});
|
||||
|
||||
header.append(deckSelect, removeButton);
|
||||
row.append(header, fieldActions, checkboxList);
|
||||
const error = state.deckFieldNamesErrors.get(deckName);
|
||||
if (error) {
|
||||
const hint = createElement('div', 'control-hint error');
|
||||
hint.textContent = error;
|
||||
row.append(hint);
|
||||
}
|
||||
container.append(row);
|
||||
}
|
||||
|
||||
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Add Deck';
|
||||
addButton.disabled = deckNames.length === 0;
|
||||
addButton.addEventListener('click', () => {
|
||||
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||
const nextDeckName = deckNames.find((deckName) => !Object.hasOwn(nextDecks, deckName));
|
||||
if (!nextDeckName) return;
|
||||
nextDecks[nextDeckName] = [];
|
||||
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||
requestRender();
|
||||
});
|
||||
container.append(addButton);
|
||||
|
||||
if (state.deckNamesError) {
|
||||
const hint = createElement('div', 'control-hint error');
|
||||
hint.textContent = state.deckNamesError;
|
||||
container.append(hint);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
|
||||
export interface SettingsControlContext {
|
||||
setFieldError(path: string, message: string | null): void;
|
||||
resetDraftPath(path: string, defaultValue?: ConfigSettingsSnapshotValue): void;
|
||||
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
|
||||
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
|
||||
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
|
||||
export function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
tagName: K,
|
||||
className?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function addOption(select: HTMLSelectElement, value: string, label = value): void {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
export function uniqueSorted(values: Iterable<string>): string[] {
|
||||
return [...new Set([...values].filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function isSecretSnapshotValue(
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): value is { configured: boolean } {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'configured' in value &&
|
||||
typeof (value as { configured?: unknown }).configured === 'boolean',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
import { toConfigDraftValue, toSettingsDisplayValue } from './settings-model';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
configureAnkiControls,
|
||||
initializeAnkiControls,
|
||||
renderAnkiFieldInput,
|
||||
renderAnkiNoteTypeInput,
|
||||
renderKnownWordsDecksInput,
|
||||
renderNoteFieldModelPicker,
|
||||
} from './settings-anki-controls';
|
||||
import type { SettingsControlContext } from './settings-control-context';
|
||||
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
|
||||
import {
|
||||
configureKeybindingControls,
|
||||
renderKeyboardInput,
|
||||
renderMpvKeybindingsInput,
|
||||
} from './settings-keybinding-controls';
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssScopeForPath,
|
||||
parseSubtitleCssDeclarations,
|
||||
serializeSubtitleCssDeclarations,
|
||||
} from './subtitle-style-css';
|
||||
|
||||
export { renderNoteFieldModelPicker };
|
||||
|
||||
export function configureSettingsControls(options: { requestRender: () => void }): void {
|
||||
configureAnkiControls(options);
|
||||
configureKeybindingControls(options);
|
||||
}
|
||||
|
||||
export function initializeSettingsControls(
|
||||
values: Record<string, ConfigSettingsSnapshotValue>,
|
||||
): void {
|
||||
initializeAnkiControls(values);
|
||||
}
|
||||
|
||||
function renderColorListInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const colors = Array.isArray(value) ? (value as string[]) : [];
|
||||
const container = createElement('div', 'color-list');
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
const row = createElement('div', 'color-list-row');
|
||||
const label = createElement('span', 'color-list-label');
|
||||
label.textContent = `Band ${i + 1}`;
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'color';
|
||||
input.value = colors[i] ?? '#000000';
|
||||
input.addEventListener('input', () => {
|
||||
const updated = [...colors];
|
||||
updated[i] = input.value;
|
||||
context.updateDraft(field.configPath, updated);
|
||||
});
|
||||
row.append(label, input);
|
||||
container.append(row);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function renderJsonInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
context.updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||
textarea.classList.remove('invalid');
|
||||
context.setFieldError(field.configPath, null);
|
||||
} catch {
|
||||
textarea.classList.add('invalid');
|
||||
context.setFieldError(field.configPath, 'Invalid JSON');
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderStringListInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||
textarea.addEventListener('input', () => {
|
||||
context.updateDraft(
|
||||
field.configPath,
|
||||
textarea.value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderCssDeclarationsInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const scope = getSubtitleCssScopeForPath(field.configPath);
|
||||
const textarea = createElement(
|
||||
'textarea',
|
||||
'config-textarea css-declarations',
|
||||
) as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
if (!scope) return textarea;
|
||||
|
||||
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||
const values: Record<string, ConfigSettingsSnapshotValue | undefined> = {
|
||||
[field.configPath]: context.valueForPath(field.configPath),
|
||||
};
|
||||
for (const path of managedPaths) {
|
||||
values[path] = context.valueForPath(path);
|
||||
}
|
||||
textarea.value = serializeSubtitleCssDeclarations(scope, values);
|
||||
textarea.addEventListener('input', () => {
|
||||
const parsed = parseSubtitleCssDeclarations(textarea.value);
|
||||
if (!parsed.ok) {
|
||||
textarea.classList.add('invalid');
|
||||
context.setFieldError(field.configPath, parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.classList.remove('invalid');
|
||||
context.setFieldError(field.configPath, null);
|
||||
context.updateDraft(field.configPath, parsed.declarations);
|
||||
for (const path of managedPaths) {
|
||||
context.resetDraftPath(path, undefined);
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
export function renderControl(
|
||||
field: ConfigSettingsField,
|
||||
context: SettingsControlContext,
|
||||
): HTMLElement {
|
||||
const value = toSettingsDisplayValue(field.configPath, context.valueForField(field));
|
||||
|
||||
if (field.control === 'keyboard-shortcut') {
|
||||
return renderKeyboardInput(context, field, 'accelerator');
|
||||
}
|
||||
|
||||
if (field.control === 'key-code') {
|
||||
return renderKeyboardInput(context, field, 'code');
|
||||
}
|
||||
|
||||
if (field.control === 'mpv-key') {
|
||||
return renderKeyboardInput(context, field, 'mpv-key');
|
||||
}
|
||||
|
||||
if (field.control === 'known-words-decks') {
|
||||
return renderKnownWordsDecksInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'anki-note-type') {
|
||||
return renderAnkiNoteTypeInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'anki-field') {
|
||||
return renderAnkiFieldInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'mpv-keybindings') {
|
||||
return renderMpvKeybindingsInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(value);
|
||||
input.addEventListener('change', () => context.updateDraft(field.configPath, input.checked));
|
||||
const track = createElement('span', 'switch-track');
|
||||
label.append(input, track);
|
||||
return label;
|
||||
}
|
||||
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
const numericValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof field.defaultValue === 'number'
|
||||
? field.defaultValue
|
||||
: NaN;
|
||||
input.value = Number.isFinite(numericValue) ? String(numericValue) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
input.classList.remove('invalid');
|
||||
context.setFieldError(field.configPath, null);
|
||||
context.updateDraft(field.configPath, toConfigDraftValue(field.configPath, next.value));
|
||||
} else {
|
||||
input.classList.add('invalid');
|
||||
context.setFieldError(field.configPath, 'Invalid number');
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
if (field.control === 'select') {
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const enumValue of field.enumValues ?? []) {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = enumValue;
|
||||
option.textContent = enumValue;
|
||||
option.selected = enumValue === value;
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field.control === 'color-list') {
|
||||
return renderColorListInput(context, field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'string-list') {
|
||||
return renderStringListInput(context, field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'json') {
|
||||
return renderJsonInput(context, field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'css-declarations') {
|
||||
return renderCssDeclarationsInput(context, field);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.addEventListener('input', () => context.updateDraft(field.configPath, textarea.value));
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = field.control === 'secret' ? 'password' : field.control;
|
||||
if (field.control === 'secret') {
|
||||
input.placeholder =
|
||||
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value.trim().length === 0) {
|
||||
context.updateDraft(field.configPath, value);
|
||||
return;
|
||||
}
|
||||
context.updateDraft(field.configPath, input.value);
|
||||
});
|
||||
} else {
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
input.addEventListener('input', () => context.updateDraft(field.configPath, input.value));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
import { getFieldTitleBadges } from './settings-field-layout';
|
||||
|
||||
const advancedRestartField: ConfigSettingsField = {
|
||||
id: 'ankiConnect.knownWords.highlightEnabled',
|
||||
label: 'Enabled',
|
||||
description: 'Enable fast local highlighting for words already known in Anki.',
|
||||
configPath: 'ankiConnect.knownWords.highlightEnabled',
|
||||
category: 'mining-anki',
|
||||
section: 'Known Words',
|
||||
control: 'boolean',
|
||||
defaultValue: false,
|
||||
restartBehavior: 'restart',
|
||||
advanced: true,
|
||||
};
|
||||
|
||||
test('field title badges show restart status without config paths or advanced labels', () => {
|
||||
const badges = getFieldTitleBadges(advancedRestartField);
|
||||
|
||||
assert.deepEqual(badges, [
|
||||
{
|
||||
className: 'restart-chip restart',
|
||||
text: 'Restart',
|
||||
},
|
||||
]);
|
||||
assert.equal(JSON.stringify(badges).includes(advancedRestartField.configPath), false);
|
||||
assert.equal(JSON.stringify(badges).includes('Advanced'), false);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
|
||||
export interface FieldTitleBadge {
|
||||
className: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function getFieldTitleBadges(field: ConfigSettingsField): FieldTitleBadge[] {
|
||||
return [
|
||||
{
|
||||
className: `restart-chip ${field.restartBehavior}`,
|
||||
text: field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { DEFAULT_KEYBINDINGS } from '../config/definitions/shared';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
import {
|
||||
buildMpvKeybindingConfigValue,
|
||||
createMpvKeybindingRows,
|
||||
keyboardEventToConfigKey,
|
||||
parseMpvCommandText,
|
||||
type KeyInputMode,
|
||||
type MpvKeybindingRow,
|
||||
} from './key-input';
|
||||
import type { SettingsControlContext } from './settings-control-context';
|
||||
import { createElement } from './settings-control-dom';
|
||||
|
||||
let activeKeyLearningStop: (() => void) | null = null;
|
||||
let requestRender = (): void => undefined;
|
||||
|
||||
export function configureKeybindingControls(options: { requestRender: () => void }): void {
|
||||
requestRender = options.requestRender;
|
||||
}
|
||||
|
||||
function startKeyLearning(
|
||||
button: HTMLButtonElement,
|
||||
mode: KeyInputMode,
|
||||
onValue: (value: string) => void,
|
||||
): void {
|
||||
activeKeyLearningStop?.();
|
||||
const previousText = button.textContent ?? '';
|
||||
button.textContent = 'Press Keys...';
|
||||
button.classList.add('learning');
|
||||
let onKeyDown: (event: KeyboardEvent) => void;
|
||||
let onBlur: () => void;
|
||||
let onMouseDown: (event: MouseEvent) => void;
|
||||
|
||||
const stop = (): void => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('blur', onBlur, true);
|
||||
window.removeEventListener('mousedown', onMouseDown, true);
|
||||
button.classList.remove('learning');
|
||||
if (button.textContent === 'Press Keys...') {
|
||||
button.textContent = previousText;
|
||||
}
|
||||
if (activeKeyLearningStop === stop) {
|
||||
activeKeyLearningStop = null;
|
||||
}
|
||||
};
|
||||
|
||||
onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const next = keyboardEventToConfigKey(event, mode);
|
||||
if (!next) return;
|
||||
stop();
|
||||
onValue(next);
|
||||
};
|
||||
onBlur = (): void => stop();
|
||||
onMouseDown = (event: MouseEvent): void => {
|
||||
if (event.target !== button) {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('blur', onBlur, true);
|
||||
window.addEventListener('mousedown', onMouseDown, true);
|
||||
activeKeyLearningStop = stop;
|
||||
}
|
||||
|
||||
function renderKeyLearnButton(
|
||||
value: string,
|
||||
mode: KeyInputMode,
|
||||
onValue: (value: string) => void,
|
||||
): HTMLButtonElement {
|
||||
const button = createElement('button', 'key-learn-button') as HTMLButtonElement;
|
||||
button.type = 'button';
|
||||
button.textContent = value || 'Unset';
|
||||
button.addEventListener('click', () =>
|
||||
startKeyLearning(button, mode, (next) => {
|
||||
button.textContent = next;
|
||||
onValue(next);
|
||||
}),
|
||||
);
|
||||
return button;
|
||||
}
|
||||
|
||||
export function renderKeyboardInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
mode: KeyInputMode,
|
||||
): HTMLElement {
|
||||
const value = context.valueForField(field);
|
||||
return renderKeyLearnButton(typeof value === 'string' ? value : '', mode, (next) => {
|
||||
context.updateDraft(field.configPath, next);
|
||||
});
|
||||
}
|
||||
|
||||
function applyMpvRows(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
rows: MpvKeybindingRow[],
|
||||
): void {
|
||||
context.updateDraft(field.configPath, buildMpvKeybindingConfigValue(DEFAULT_KEYBINDINGS, rows));
|
||||
}
|
||||
|
||||
export function renderMpvKeybindingsInput(
|
||||
context: SettingsControlContext,
|
||||
field: ConfigSettingsField,
|
||||
): HTMLElement {
|
||||
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
|
||||
const container = createElement('div', 'keybinding-editor');
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const item = createElement('div', 'keybinding-row');
|
||||
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
|
||||
row.key = next;
|
||||
applyMpvRows(context, field, rows);
|
||||
});
|
||||
const command = createElement('input', 'config-input mono-input') as HTMLInputElement;
|
||||
command.type = 'text';
|
||||
command.value = row.commandText;
|
||||
command.placeholder = '["cycle","pause"]';
|
||||
command.addEventListener('input', () => {
|
||||
const parsed = parseMpvCommandText(command.value);
|
||||
if (parsed === undefined) {
|
||||
command.classList.add('invalid');
|
||||
context.setFieldError(field.configPath, 'Invalid MPV command JSON');
|
||||
return;
|
||||
}
|
||||
command.classList.remove('invalid');
|
||||
context.setFieldError(field.configPath, null);
|
||||
row.command = parsed;
|
||||
row.commandText = command.value;
|
||||
applyMpvRows(context, field, rows);
|
||||
});
|
||||
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Remove';
|
||||
removeButton.addEventListener('click', () => {
|
||||
const nextRows = rows.filter((_, index) => index !== i);
|
||||
applyMpvRows(context, field, nextRows);
|
||||
requestRender();
|
||||
});
|
||||
item.append(keyButton, command, removeButton);
|
||||
container.append(item);
|
||||
}
|
||||
|
||||
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
|
||||
addButton.type = 'button';
|
||||
addButton.textContent = 'Add Binding';
|
||||
addButton.addEventListener('click', () => {
|
||||
rows.push({ defaultKey: '', key: '', command: null, commandText: '', isDefault: false });
|
||||
applyMpvRows(context, field, rows);
|
||||
requestRender();
|
||||
});
|
||||
container.append(addButton);
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('settings toolbar does not expose an open file button', () => {
|
||||
const html = fs.readFileSync(path.join(process.cwd(), 'src/settings/index.html'), 'utf8');
|
||||
|
||||
assert.equal(html.includes('id="openFileButton"'), false);
|
||||
assert.equal(html.includes('Open File'), false);
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
setDraftValue,
|
||||
resetDraftPath,
|
||||
getDirtyOperations,
|
||||
toConfigDraftValue,
|
||||
toSettingsDisplayValue,
|
||||
} from './settings-model';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
|
||||
@@ -14,8 +17,8 @@ const fields: ConfigSettingsField[] = [
|
||||
label: 'Pause on subtitle hover',
|
||||
description: 'Pause while hovering subtitles.',
|
||||
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
category: 'viewing',
|
||||
section: 'Playback pause behavior',
|
||||
category: 'behavior',
|
||||
section: 'Playback Behavior',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'hot-reload',
|
||||
@@ -31,16 +34,80 @@ const fields: ConfigSettingsField[] = [
|
||||
defaultValue: true,
|
||||
restartBehavior: 'restart',
|
||||
},
|
||||
{
|
||||
id: 'subtitleStyle.fontSize',
|
||||
label: 'Font Size',
|
||||
description: 'Hidden behind CSS editor.',
|
||||
configPath: 'subtitleStyle.fontSize',
|
||||
category: 'appearance',
|
||||
section: 'Primary Subtitle Appearance',
|
||||
control: 'number',
|
||||
defaultValue: 35,
|
||||
restartBehavior: 'hot-reload',
|
||||
settingsHidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
test('filterSettingsFields searches label, section, and config path', () => {
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map(
|
||||
filterSettingsFields(fields, { category: 'behavior', query: 'hover' }).map(
|
||||
(field) => field.configPath,
|
||||
),
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'viewing', query: 'anki' }), []);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { query: 'anki' }).map((field) => field.configPath),
|
||||
['ankiConnect.enabled'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { query: 'pause hover' }).map((field) => field.configPath),
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { query: 'pause anki' }), []);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
|
||||
});
|
||||
|
||||
test('filterSettingsFields normalizes punctuation in query terms', () => {
|
||||
const nPlusOneFields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'ankiConnect.nPlusOne.enabled',
|
||||
label: 'Enable N+1',
|
||||
description: 'Highlight N+1 cards.',
|
||||
configPath: 'ankiConnect.nPlusOne.enabled',
|
||||
category: 'mining-anki',
|
||||
section: 'N+1',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'hot-reload',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(nPlusOneFields, { query: 'n+1' }).map((field) => field.configPath),
|
||||
['ankiConnect.nPlusOne.enabled'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterSettingsFields preserves non-Latin query terms', () => {
|
||||
const japaneseFields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'subtitleStyle.japaneseFontFamily',
|
||||
label: '日本語フォント',
|
||||
description: '字幕の表示に使う書体。',
|
||||
configPath: 'subtitleStyle.japaneseFontFamily',
|
||||
category: 'appearance',
|
||||
section: 'Primary Subtitle Appearance',
|
||||
control: 'text',
|
||||
defaultValue: '',
|
||||
restartBehavior: 'hot-reload',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(japaneseFields, { query: '日本語' }).map((field) => field.configPath),
|
||||
['subtitleStyle.japaneseFontFamily'],
|
||||
);
|
||||
});
|
||||
|
||||
test('settings draft tracks dirty set and emits save operations', () => {
|
||||
@@ -60,3 +127,40 @@ test('settings draft tracks dirty set and emits save operations', () => {
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
||||
assert.deepEqual(getDirtyOperations(draft), []);
|
||||
});
|
||||
|
||||
test('settings draft emits reset operations for css-editor-owned legacy style paths', () => {
|
||||
const draft = createSettingsDraft({
|
||||
'subtitleStyle.css': {},
|
||||
'subtitleStyle.fontSize': 35,
|
||||
});
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.css', { 'font-size': '42px' });
|
||||
resetDraftPath(draft, 'subtitleStyle.fontSize', undefined);
|
||||
|
||||
assert.deepEqual(getDirtyOperations(draft), [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.css',
|
||||
value: { 'font-size': '42px' },
|
||||
},
|
||||
{
|
||||
op: 'reset',
|
||||
path: 'subtitleStyle.fontSize',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('discord presence update interval displays seconds while saving milliseconds', () => {
|
||||
const path = 'discordPresence.updateIntervalMs';
|
||||
|
||||
assert.equal(toSettingsDisplayValue(path, 3000), 3);
|
||||
assert.equal(toConfigDraftValue(path, 2.5), 2500);
|
||||
});
|
||||
|
||||
test('websocket enabled select values save booleans instead of strings', () => {
|
||||
assert.equal(toSettingsDisplayValue('websocket.enabled', true), 'true');
|
||||
assert.equal(toSettingsDisplayValue('websocket.enabled', false), 'false');
|
||||
assert.equal(toConfigDraftValue('websocket.enabled', 'true'), true);
|
||||
assert.equal(toConfigDraftValue('websocket.enabled', 'false'), false);
|
||||
assert.equal(toConfigDraftValue('websocket.enabled', 'auto'), 'auto');
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '../types/settings';
|
||||
|
||||
export interface SettingsFilter {
|
||||
category: ConfigSettingsCategory;
|
||||
category?: ConfigSettingsCategory;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,16 @@ export interface SettingsDraft {
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string | undefined): string {
|
||||
return (query ?? '').trim().toLowerCase();
|
||||
return (query ?? '').trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function searchableText(parts: Array<string | undefined>): string {
|
||||
return parts
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
@@ -29,23 +38,26 @@ export function filterSettingsFields(
|
||||
filter: SettingsFilter,
|
||||
): ConfigSettingsField[] {
|
||||
const query = normalizeQuery(filter.query);
|
||||
const terms = query.length > 0 ? searchableText([query]).split(/\s+/).filter(Boolean) : [];
|
||||
return fields.filter((field) => {
|
||||
if (field.category !== filter.category || field.legacyHidden) {
|
||||
if (field.legacyHidden || field.settingsHidden) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
if (filter.category && field.category !== filter.category) {
|
||||
return false;
|
||||
}
|
||||
if (!query || terms.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
const haystack = searchableText([
|
||||
field.label,
|
||||
field.description,
|
||||
field.configPath,
|
||||
field.section,
|
||||
field.subsection ?? '',
|
||||
field.enumValues?.join(' ') ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
]);
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +71,33 @@ export function createSettingsDraft(
|
||||
};
|
||||
}
|
||||
|
||||
export function toSettingsDisplayValue(
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): ConfigSettingsSnapshotValue {
|
||||
if (path === 'websocket.enabled' && typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
|
||||
return value / 1000;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function toConfigDraftValue(
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): ConfigSettingsSnapshotValue {
|
||||
if (path === 'websocket.enabled') {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
if (path === 'discordPresence.updateIntervalMs' && typeof value === 'number') {
|
||||
return Math.round(value * 1000);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function setDraftValue(
|
||||
draft: SettingsDraft,
|
||||
path: string,
|
||||
|
||||
+127
-191
@@ -6,7 +6,12 @@ import type {
|
||||
ConfigSettingsSnapshot,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
configureSettingsControls,
|
||||
initializeSettingsControls,
|
||||
renderControl,
|
||||
renderNoteFieldModelPicker,
|
||||
} from './settings-controls';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
setDraftValue,
|
||||
type SettingsDraft,
|
||||
} from './settings-model';
|
||||
import { getFieldTitleBadges } from './settings-field-layout';
|
||||
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -23,9 +30,9 @@ declare global {
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
viewing: 'Viewing',
|
||||
appearance: 'Appearance',
|
||||
behavior: 'Behavior',
|
||||
'mining-anki': 'Mining & Anki',
|
||||
'playback-sources': 'Playback & Sources',
|
||||
input: 'Input',
|
||||
integrations: 'Integrations',
|
||||
'tracking-app': 'Tracking & App',
|
||||
@@ -33,9 +40,9 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'viewing',
|
||||
'appearance',
|
||||
'behavior',
|
||||
'mining-anki',
|
||||
'playback-sources',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
@@ -51,7 +58,7 @@ const state: {
|
||||
} = {
|
||||
snapshot: null,
|
||||
draft: null,
|
||||
category: 'viewing',
|
||||
category: 'appearance',
|
||||
query: '',
|
||||
inputErrors: new Map(),
|
||||
};
|
||||
@@ -69,19 +76,12 @@ const dom = {
|
||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||
settingsContent: getElement<HTMLElement>('settingsContent'),
|
||||
};
|
||||
|
||||
function isSecretSnapshotValue(
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): value is { configured: boolean } {
|
||||
return Boolean(value && typeof value === 'object' && 'configured' in value);
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
||||
dom.statusBanner.textContent = message;
|
||||
dom.statusBanner.className = `status-banner ${tone}`;
|
||||
@@ -113,26 +113,20 @@ function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
return element;
|
||||
}
|
||||
|
||||
function createFieldMeta(field: ConfigSettingsField): HTMLElement {
|
||||
const meta = createElement('div', 'field-meta');
|
||||
const path = createElement('code');
|
||||
path.textContent = field.configPath;
|
||||
meta.append(path);
|
||||
|
||||
const restart = createElement('span', `restart-chip ${field.restartBehavior}`);
|
||||
restart.textContent = field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart';
|
||||
meta.append(restart);
|
||||
|
||||
if (field.advanced) {
|
||||
const advanced = createElement('span', 'advanced-chip');
|
||||
advanced.textContent = 'Advanced';
|
||||
meta.append(advanced);
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
if (!state.draft) {
|
||||
return field.defaultValue;
|
||||
}
|
||||
return meta;
|
||||
return Object.hasOwn(state.draft.values, field.configPath)
|
||||
? state.draft.values[field.configPath]
|
||||
: field.defaultValue;
|
||||
}
|
||||
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
return state.draft?.values[field.configPath] ?? field.defaultValue;
|
||||
function valueForPath(path: string): ConfigSettingsSnapshotValue | undefined {
|
||||
if (!state.draft || !Object.hasOwn(state.draft.values, path)) {
|
||||
return undefined;
|
||||
}
|
||||
return state.draft.values[path];
|
||||
}
|
||||
|
||||
function setFieldError(path: string, message: string | null): void {
|
||||
@@ -150,126 +144,11 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderJsonInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||
textarea.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
} catch {
|
||||
textarea.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid JSON');
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderStringListInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||
textarea.addEventListener('input', () => {
|
||||
updateDraft(
|
||||
field.configPath,
|
||||
textarea.value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderControl(field: ConfigSettingsField): HTMLElement {
|
||||
const value = valueForField(field);
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(value);
|
||||
input.addEventListener('change', () => updateDraft(field.configPath, input.checked));
|
||||
const track = createElement('span', 'switch-track');
|
||||
label.append(input, track);
|
||||
return label;
|
||||
}
|
||||
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
input.value = typeof value === 'number' ? String(value) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
input.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
updateDraft(field.configPath, next.value);
|
||||
} else {
|
||||
input.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid number');
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
if (field.control === 'select') {
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const enumValue of field.enumValues ?? []) {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = enumValue;
|
||||
option.textContent = enumValue;
|
||||
option.selected = enumValue === value;
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => updateDraft(field.configPath, select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field.control === 'string-list') {
|
||||
return renderStringListInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'json') {
|
||||
return renderJsonInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value));
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = field.control === 'secret' ? 'password' : field.control;
|
||||
if (field.control === 'secret') {
|
||||
input.placeholder =
|
||||
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value.trim().length === 0) {
|
||||
if (state.draft) {
|
||||
setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]);
|
||||
}
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
updateDraft(field.configPath, input.value);
|
||||
});
|
||||
} else {
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
input.addEventListener('input', () => updateDraft(field.configPath, input.value));
|
||||
}
|
||||
return input;
|
||||
function resetDraftPathContext(path: string, defaultValue?: ConfigSettingsSnapshotValue): void {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, path, defaultValue);
|
||||
state.inputErrors.delete(path);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||
@@ -301,7 +180,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.categoryNav.replaceChildren();
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const count = snapshot.fields.filter(
|
||||
(field) => field.category === category && !field.legacyHidden,
|
||||
(field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
|
||||
).length;
|
||||
if (count === 0) continue;
|
||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||
@@ -315,6 +194,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
button.addEventListener('click', () => {
|
||||
state.category = category;
|
||||
render();
|
||||
dom.settingsContent.scrollTop = 0;
|
||||
});
|
||||
dom.categoryNav.append(button);
|
||||
}
|
||||
@@ -324,19 +204,40 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
const row = createElement('article', 'field-row');
|
||||
const header = createElement('div', 'field-copy');
|
||||
const label = createElement('h3');
|
||||
label.textContent = field.label;
|
||||
const labelText = createElement('span', 'field-title-text');
|
||||
labelText.textContent = field.label;
|
||||
label.append(labelText);
|
||||
for (const badge of getFieldTitleBadges(field)) {
|
||||
const badgeEl = createElement('span', badge.className);
|
||||
badgeEl.textContent = badge.text;
|
||||
label.append(badgeEl);
|
||||
}
|
||||
const description = createElement('p');
|
||||
description.textContent = field.description;
|
||||
header.append(label, description, createFieldMeta(field));
|
||||
header.append(label, description);
|
||||
|
||||
const controlWrap = createElement('div', 'field-control');
|
||||
controlWrap.append(renderControl(field));
|
||||
controlWrap.append(
|
||||
renderControl(field, {
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Reset';
|
||||
resetButton.addEventListener('click', () => {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||
const cssScope = getSubtitleCssScopeForPath(field.configPath);
|
||||
if (cssScope) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(cssScope)) {
|
||||
resetDraftPath(state.draft, path, undefined);
|
||||
}
|
||||
}
|
||||
state.inputErrors.delete(field.configPath);
|
||||
render();
|
||||
});
|
||||
@@ -347,13 +248,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
|
||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.settingsContent.replaceChildren();
|
||||
const query = state.query.trim();
|
||||
const fields = filterSettingsFields(snapshot.fields, {
|
||||
category: state.category,
|
||||
query: state.query,
|
||||
category: query ? undefined : state.category,
|
||||
query,
|
||||
});
|
||||
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
if (query) {
|
||||
const categoryCount = new Set(fields.map((field) => field.category)).size;
|
||||
dom.categoryTitle.textContent = 'Search results';
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}${
|
||||
categoryCount > 0
|
||||
? ` across ${categoryCount} categor${categoryCount === 1 ? 'y' : 'ies'}`
|
||||
: ''
|
||||
}`;
|
||||
} else {
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
const empty = createElement('div', 'empty-state');
|
||||
@@ -362,19 +274,41 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = new Map<string, ConfigSettingsField[]>();
|
||||
const sections = new Map<
|
||||
string,
|
||||
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
|
||||
>();
|
||||
for (const field of fields) {
|
||||
const sectionFields = sections.get(field.section) ?? [];
|
||||
sectionFields.push(field);
|
||||
sections.set(field.section, sectionFields);
|
||||
const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
|
||||
const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
|
||||
section.fields.push(field);
|
||||
sections.set(title, section);
|
||||
}
|
||||
|
||||
for (const [section, sectionFields] of sections) {
|
||||
for (const section of sections.values()) {
|
||||
const sectionEl = createElement('section', 'settings-section');
|
||||
const title = createElement('h2');
|
||||
title.textContent = section;
|
||||
title.textContent = section.title;
|
||||
sectionEl.append(title);
|
||||
for (const field of sectionFields) {
|
||||
if (section.rawSection === 'Note Fields') {
|
||||
sectionEl.append(
|
||||
renderNoteFieldModelPicker({
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let currentSubsection = '';
|
||||
for (const field of section.fields) {
|
||||
if (field.subsection && field.subsection !== currentSubsection) {
|
||||
currentSubsection = field.subsection;
|
||||
const subsectionTitle = createElement('h3', 'settings-subsection-title');
|
||||
subsectionTitle.textContent = field.subsection;
|
||||
sectionEl.append(subsectionTitle);
|
||||
}
|
||||
sectionEl.append(renderField(field));
|
||||
}
|
||||
dom.settingsContent.append(sectionEl);
|
||||
@@ -390,11 +324,14 @@ function render(): void {
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
configureSettingsControls({ requestRender: render });
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
clearStatus();
|
||||
const snapshot = await window.configSettingsAPI.getSnapshot();
|
||||
state.snapshot = snapshot;
|
||||
state.draft = createSettingsDraft(snapshot.values);
|
||||
initializeSettingsControls(snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
render();
|
||||
}
|
||||
@@ -406,34 +343,36 @@ async function save(): Promise<void> {
|
||||
|
||||
dom.saveButton.disabled = true;
|
||||
setStatus('Saving...', 'info');
|
||||
let result;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.savePatch({ operations });
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
result = await window.configSettingsAPI.savePatch({ operations });
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
||||
} finally {
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
dom.searchInput.addEventListener('input', () => {
|
||||
@@ -443,9 +382,6 @@ dom.searchInput.addEventListener('input', () => {
|
||||
dom.saveButton.addEventListener('click', () => {
|
||||
void save();
|
||||
});
|
||||
dom.openFileButton.addEventListener('click', () => {
|
||||
void window.configSettingsAPI.openSettingsFile();
|
||||
});
|
||||
|
||||
void loadSnapshot().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||
|
||||
+214
-21
@@ -262,7 +262,7 @@ h1 {
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 210px;
|
||||
width: min(360px, 34vw);
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
@@ -273,6 +273,13 @@ h1 {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.mono-input {
|
||||
font-family:
|
||||
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
width: min(420px, 100%);
|
||||
min-height: 138px;
|
||||
@@ -289,6 +296,12 @@ h1 {
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
.config-textarea.css-declarations {
|
||||
width: min(560px, 100%);
|
||||
min-height: 188px;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.search-input:hover,
|
||||
.config-input:hover,
|
||||
.config-textarea:hover {
|
||||
@@ -324,7 +337,8 @@ select.config-input option {
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
.reset-button,
|
||||
.key-learn-button {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -340,7 +354,8 @@ select.config-input option {
|
||||
|
||||
.primary-button:active,
|
||||
.secondary-button:active,
|
||||
.reset-button:active {
|
||||
.reset-button:active,
|
||||
.key-learn-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@@ -365,19 +380,37 @@ select.config-input option {
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
.reset-button,
|
||||
.key-learn-button {
|
||||
padding: 0 13px;
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secondary-button:hover,
|
||||
.reset-button:hover {
|
||||
.reset-button:hover,
|
||||
.key-learn-button:hover {
|
||||
border-color: rgba(138, 173, 244, 0.45);
|
||||
background: rgba(73, 77, 100, 0.6);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.key-learn-button {
|
||||
min-width: 146px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
font-family:
|
||||
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.key-learn-button.learning {
|
||||
border-color: rgba(238, 212, 159, 0.58);
|
||||
background: rgba(238, 212, 159, 0.1);
|
||||
color: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
@@ -479,6 +512,18 @@ code {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-subsection-title {
|
||||
margin: 0;
|
||||
padding: 11px 16px 8px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
background: rgba(24, 25, 38, 0.32);
|
||||
color: var(--ctp-sky);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
|
||||
@@ -492,7 +537,19 @@ code {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.settings-subsection-title + .field-row {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.helper-row {
|
||||
background: rgba(138, 173, 244, 0.04);
|
||||
}
|
||||
|
||||
.field-copy h3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -500,6 +557,10 @@ code {
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.field-title-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-copy p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
@@ -508,16 +569,10 @@ code {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.restart-chip {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.restart-chip,
|
||||
.advanced-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -541,12 +596,6 @@ code {
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.advanced-chip {
|
||||
border-color: rgba(198, 160, 246, 0.4);
|
||||
background: rgba(198, 160, 246, 0.1);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -555,6 +604,116 @@ code {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stacked-control,
|
||||
.deck-field-editor,
|
||||
.keybinding-editor {
|
||||
display: flex;
|
||||
width: min(520px, 100%);
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deck-field-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.4);
|
||||
}
|
||||
|
||||
.deck-field-row-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deck-field-row-name {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.deck-field-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-checkbox-list {
|
||||
display: flex;
|
||||
max-height: 148px;
|
||||
min-height: 44px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 25, 38, 0.58);
|
||||
}
|
||||
|
||||
.field-checkbox-row {
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.field-checkbox-row:hover {
|
||||
background: rgba(138, 173, 244, 0.1);
|
||||
}
|
||||
|
||||
.field-checkbox-row input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
accent-color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.field-checkbox-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(146px, 0.78fr) minmax(180px, 1.22fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.multi-select {
|
||||
min-height: 92px;
|
||||
padding-right: 10px;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-hint {
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.control-hint.error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -611,6 +770,39 @@ code {
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
|
||||
}
|
||||
|
||||
.color-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.color-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.color-list-label {
|
||||
min-width: 52px;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.color-list-row input[type='color'] {
|
||||
width: 52px;
|
||||
min-height: 32px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-list-row input[type='color']:hover {
|
||||
border-color: rgba(138, 173, 244, 0.32);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px;
|
||||
border: 1px dashed var(--line);
|
||||
@@ -638,7 +830,8 @@ code {
|
||||
|
||||
.settings-toolbar,
|
||||
.field-row,
|
||||
.field-control {
|
||||
.field-control,
|
||||
.keybinding-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
parseSubtitleCssDeclarations,
|
||||
serializeSubtitleCssDeclarations,
|
||||
} from './subtitle-style-css';
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds primary CSS from all managed appearance config', () => {
|
||||
const css = serializeSubtitleCssDeclarations('primary', {
|
||||
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
|
||||
'subtitleStyle.fontSize': 35,
|
||||
'subtitleStyle.fontColor': '#cad3f5',
|
||||
'subtitleStyle.backgroundColor': 'transparent',
|
||||
'subtitleStyle.hoverTokenColor': '#f4dbd6',
|
||||
'subtitleStyle.hoverTokenBackgroundColor': 'transparent',
|
||||
'subtitleStyle.paintOrder': 'stroke fill',
|
||||
'subtitleStyle.WebkitTextStroke': '1.5px #000',
|
||||
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
|
||||
'subtitleStyle.css': {
|
||||
filter: 'drop-shadow(0 0 8px #000)',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||
assert.match(css, /font-size: 35px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: transparent;/);
|
||||
assert.match(css, /--subtitle-hover-token-color: #f4dbd6;/);
|
||||
assert.match(css, /--subtitle-hover-token-background-color: transparent;/);
|
||||
assert.match(css, /paint-order: stroke fill;/);
|
||||
assert.match(css, /-webkit-text-stroke: 1.5px #000;/);
|
||||
assert.doesNotMatch(css, /--subtitle-known-word-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-n-plus-one-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-name-match-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-jlpt-n1-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-frequency-single-color:/);
|
||||
assert.doesNotMatch(css, /--subtitle-frequency-band-1-color:/);
|
||||
assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/);
|
||||
assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/);
|
||||
assert.match(css, /--subtitle-outline: 1px;/);
|
||||
});
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => {
|
||||
const css = serializeSubtitleCssDeclarations('secondary', {
|
||||
'subtitleStyle.secondary.fontFamily': 'Noto Sans, sans-serif',
|
||||
'subtitleStyle.secondary.fontSize': 24,
|
||||
'subtitleStyle.secondary.fontColor': '#cad3f5',
|
||||
'subtitleStyle.secondary.backgroundColor': 'transparent',
|
||||
'subtitleStyle.secondary.css': {
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: Noto Sans, sans-serif;/);
|
||||
assert.match(css, /font-size: 24px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: transparent;/);
|
||||
assert.match(css, /text-transform: uppercase;/);
|
||||
});
|
||||
|
||||
test('serializeSubtitleCssDeclarations builds sidebar CSS from subtitle sidebar config paths', () => {
|
||||
const css = serializeSubtitleCssDeclarations('sidebar', {
|
||||
'subtitleSidebar.fontFamily': 'M PLUS 1, sans-serif',
|
||||
'subtitleSidebar.fontSize': 16,
|
||||
'subtitleSidebar.textColor': '#cad3f5',
|
||||
'subtitleSidebar.backgroundColor': 'rgba(73, 77, 100, 0.9)',
|
||||
'subtitleSidebar.opacity': 0.95,
|
||||
'subtitleSidebar.maxWidth': 420,
|
||||
'subtitleSidebar.timestampColor': '#a5adcb',
|
||||
'subtitleSidebar.activeLineColor': '#f5bde6',
|
||||
'subtitleSidebar.activeLineBackgroundColor': 'rgba(138, 173, 244, 0.22)',
|
||||
'subtitleSidebar.hoverLineBackgroundColor': 'rgba(54, 58, 79, 0.84)',
|
||||
'subtitleSidebar.css': {
|
||||
'font-size': '18px',
|
||||
'text-wrap': 'pretty',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||
assert.match(css, /font-size: 18px;/);
|
||||
assert.match(css, /color: #cad3f5;/);
|
||||
assert.match(css, /background-color: rgba\(73, 77, 100, 0.9\);/);
|
||||
assert.match(css, /opacity: 0.95;/);
|
||||
assert.match(css, /--subtitle-sidebar-max-width: 420px;/);
|
||||
assert.match(css, /--subtitle-sidebar-timestamp-color: #a5adcb;/);
|
||||
assert.match(css, /--subtitle-sidebar-active-line-color: #f5bde6;/);
|
||||
assert.match(css, /--subtitle-sidebar-active-background-color: rgba\(138, 173, 244, 0.22\);/);
|
||||
assert.match(css, /--subtitle-sidebar-hover-background-color: rgba\(54, 58, 79, 0.84\);/);
|
||||
assert.match(css, /text-wrap: pretty;/);
|
||||
});
|
||||
|
||||
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
|
||||
assert.deepEqual(
|
||||
parseSubtitleCssDeclarations(`
|
||||
font-size: 40px;
|
||||
text-wrap: balance;
|
||||
-webkit-text-stroke: 1px black;
|
||||
--subtitle-outline: 1px;
|
||||
`),
|
||||
{
|
||||
ok: true,
|
||||
declarations: {
|
||||
'font-size': '40px',
|
||||
'text-wrap': 'balance',
|
||||
'-webkit-text-stroke': '1px black',
|
||||
'--subtitle-outline': '1px',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCssDeclarations rejects selectors and malformed declarations', () => {
|
||||
assert.equal(parseSubtitleCssDeclarations('#subtitleRoot { font-size: 40px; }').ok, false);
|
||||
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
|
||||
});
|
||||
|
||||
test('getSubtitleCssManagedConfigPaths includes all CSS-editor-owned appearance controls', () => {
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('primary').includes(''));
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('secondary').includes(''));
|
||||
assert.ok(!getSubtitleCssManagedConfigPaths('sidebar').includes(''));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
|
||||
assert.ok(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
|
||||
);
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.fontSize'));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.textColor'));
|
||||
assert.ok(getSubtitleCssManagedConfigPaths('sidebar').includes('subtitleSidebar.maxWidth'));
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.backgroundColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('secondary').includes(
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.hoverTokenBackgroundColor'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.paintOrder'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.WebkitTextStroke'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.knownWordColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nPlusOneColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.nameMatchColor'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.jlptColors.N1'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes(
|
||||
'subtitleStyle.frequencyDictionary.singleColor',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getSubtitleCssManagedConfigPaths('primary').includes(
|
||||
'subtitleStyle.frequencyDictionary.bandedColors',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||
|
||||
export type SubtitleCssScope = 'primary' | 'secondary' | 'sidebar';
|
||||
|
||||
type LegacyCssDeclaration = {
|
||||
property: string;
|
||||
paths: Partial<Record<SubtitleCssScope, string>>;
|
||||
format?: (value: unknown) => string | undefined;
|
||||
};
|
||||
|
||||
export type SubtitleCssParseResult =
|
||||
| { ok: true; declarations: Record<string, string> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
|
||||
{
|
||||
property: 'font-family',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontFamily',
|
||||
secondary: 'subtitleStyle.secondary.fontFamily',
|
||||
sidebar: 'subtitleSidebar.fontFamily',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontColor',
|
||||
secondary: 'subtitleStyle.secondary.fontColor',
|
||||
sidebar: 'subtitleSidebar.textColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'background-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.backgroundColor',
|
||||
secondary: 'subtitleStyle.secondary.backgroundColor',
|
||||
sidebar: 'subtitleSidebar.backgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-size',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontSize',
|
||||
secondary: 'subtitleStyle.secondary.fontSize',
|
||||
sidebar: 'subtitleSidebar.fontSize',
|
||||
},
|
||||
format: formatCssLengthLikeValue,
|
||||
},
|
||||
{
|
||||
property: 'font-weight',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontWeight',
|
||||
secondary: 'subtitleStyle.secondary.fontWeight',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-style',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontStyle',
|
||||
secondary: 'subtitleStyle.secondary.fontStyle',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'line-height',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.lineHeight',
|
||||
secondary: 'subtitleStyle.secondary.lineHeight',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'letter-spacing',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.letterSpacing',
|
||||
secondary: 'subtitleStyle.secondary.letterSpacing',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'word-spacing',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.wordSpacing',
|
||||
secondary: 'subtitleStyle.secondary.wordSpacing',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'font-kerning',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.fontKerning',
|
||||
secondary: 'subtitleStyle.secondary.fontKerning',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'text-rendering',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.textRendering',
|
||||
secondary: 'subtitleStyle.secondary.textRendering',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'text-shadow',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.textShadow',
|
||||
secondary: 'subtitleStyle.secondary.textShadow',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'paint-order',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.paintOrder',
|
||||
secondary: 'subtitleStyle.secondary.paintOrder',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '-webkit-text-stroke',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.WebkitTextStroke',
|
||||
secondary: 'subtitleStyle.secondary.WebkitTextStroke',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'backdrop-filter',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.backdropFilter',
|
||||
secondary: 'subtitleStyle.secondary.backdropFilter',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-hover-token-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.hoverTokenColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-hover-token-background-color',
|
||||
paths: {
|
||||
primary: 'subtitleStyle.hoverTokenBackgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: 'opacity',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.opacity',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-max-width',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.maxWidth',
|
||||
},
|
||||
format: formatCssLengthLikeValue,
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-timestamp-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.timestampColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-active-line-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.activeLineColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-active-background-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.activeLineBackgroundColor',
|
||||
},
|
||||
},
|
||||
{
|
||||
property: '--subtitle-sidebar-hover-background-color',
|
||||
paths: {
|
||||
sidebar: 'subtitleSidebar.hoverLineBackgroundColor',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/;
|
||||
|
||||
export function getSubtitleCssPath(scope: SubtitleCssScope): string {
|
||||
if (scope === 'primary') return 'subtitleStyle.css';
|
||||
if (scope === 'secondary') return 'subtitleStyle.secondary.css';
|
||||
return 'subtitleSidebar.css';
|
||||
}
|
||||
|
||||
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
LEGACY_CSS_DECLARATIONS.map((declaration) => declaration.paths[scope]).filter(
|
||||
(path): path is string => typeof path === 'string' && path.length > 0,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
|
||||
if (path === 'subtitleStyle.css') return 'primary';
|
||||
if (path === 'subtitleStyle.secondary.css') return 'secondary';
|
||||
if (path === 'subtitleSidebar.css') return 'sidebar';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeSubtitleCssDeclarations(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): string {
|
||||
return Object.entries(buildSubtitleCssDeclarationObject(scope, values))
|
||||
.map(([property, value]) => `${property}: ${value};`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildSubtitleCssDeclarationObject(
|
||||
scope: SubtitleCssScope,
|
||||
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||
): Record<string, string> {
|
||||
const declarations = new Map<string, string>();
|
||||
|
||||
for (const declaration of LEGACY_CSS_DECLARATIONS) {
|
||||
const path = declaration.paths[scope];
|
||||
if (typeof path !== 'string' || path.length === 0) continue;
|
||||
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
|
||||
if (formatted !== undefined) {
|
||||
declarations.set(declaration.property, formatted);
|
||||
}
|
||||
}
|
||||
|
||||
const cssObject = normalizeCssDeclarationRecord(values[getSubtitleCssPath(scope)]);
|
||||
for (const [property, value] of Object.entries(cssObject)) {
|
||||
declarations.set(normalizeCssPropertyName(property), value);
|
||||
}
|
||||
|
||||
return Object.fromEntries(declarations.entries());
|
||||
}
|
||||
|
||||
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { ok: true, declarations: {} };
|
||||
}
|
||||
|
||||
if (/[{}]/.test(trimmed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Enter CSS declarations only, without selectors or braces.',
|
||||
};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const rawDeclaration of splitCssDeclarations(trimmed)) {
|
||||
const declaration = rawDeclaration.trim();
|
||||
if (declaration.length === 0) continue;
|
||||
|
||||
const colonIndex = findTopLevelColon(declaration);
|
||||
if (colonIndex <= 0) {
|
||||
return { ok: false, error: `Invalid CSS declaration: ${declaration}` };
|
||||
}
|
||||
|
||||
const property = normalizeCssPropertyName(declaration.slice(0, colonIndex).trim());
|
||||
const value = declaration.slice(colonIndex + 1).trim();
|
||||
if (!CSS_PROPERTY_PATTERN.test(property)) {
|
||||
return { ok: false, error: `Invalid CSS property: ${property}` };
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return { ok: false, error: `Missing CSS value for ${property}.` };
|
||||
}
|
||||
|
||||
declarations[property] = value;
|
||||
}
|
||||
|
||||
return { ok: true, declarations };
|
||||
}
|
||||
|
||||
function normalizeCssDeclarationRecord(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const declarations: Record<string, string> = {};
|
||||
for (const [property, rawValue] of Object.entries(value)) {
|
||||
if (typeof rawValue !== 'string') continue;
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
declarations[property] = trimmed;
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function normalizeCssPropertyName(property: string): string {
|
||||
const trimmed = property.trim();
|
||||
if (trimmed.startsWith('--')) return trimmed;
|
||||
if (trimmed.includes('-')) return trimmed.toLowerCase();
|
||||
|
||||
const kebab = trimmed
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/^Webkit-/, '-webkit-')
|
||||
.toLowerCase();
|
||||
return kebab.startsWith('webkit-') ? `-${kebab}` : kebab;
|
||||
}
|
||||
|
||||
function formatCssLengthLikeValue(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return `${value}px`;
|
||||
}
|
||||
return formatCssPrimitiveValue(value);
|
||||
}
|
||||
|
||||
function formatCssPrimitiveValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined || typeof value === 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
function splitCssDeclarations(text: string): string[] {
|
||||
const declarations: string[] = [];
|
||||
let current = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
let parenDepth = 0;
|
||||
let escaping = false;
|
||||
|
||||
for (const char of text) {
|
||||
if (escaping) {
|
||||
current += char;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
current += char;
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'") {
|
||||
current += char;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ')') {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
current += char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ';' && parenDepth === 0) {
|
||||
declarations.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
declarations.push(current);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function findTopLevelColon(text: string): number {
|
||||
let quote: '"' | "'" | null = null;
|
||||
let parenDepth = 0;
|
||||
let escaping = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (escaping) {
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === ')') {
|
||||
parenDepth = Math.max(0, parenDepth - 1);
|
||||
continue;
|
||||
}
|
||||
if (char === ':' && parenDepth === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
Reference in New Issue
Block a user