feat(config): reorganize settings window and move annotation colors to subtitleStyle

- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
This commit is contained in:
2026-05-17 02:10:16 -07:00
parent a54f03f0cd
commit 309ce6ef8f
44 changed files with 2152 additions and 321 deletions
@@ -30,8 +30,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
}
return {
...config.subtitleStyle,
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
knownWordColor: config.ankiConnect.knownWords.color,
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
knownWordColor: config.subtitleStyle.knownWordColor,
nameMatchColor: config.subtitleStyle.nameMatchColor,
enableJlpt: config.subtitleStyle.enableJlpt,
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
+65 -6
View File
@@ -3,15 +3,13 @@ import path from 'node:path';
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsAnkiListResult,
ConfigSettingsField,
ConfigSettingsSaveResult,
ConfigSettingsSnapshot,
} from '../../types/settings';
import type { ReloadConfigStrictResult } from '../../config';
import {
classifyConfigHotReloadDiff,
type ConfigHotReloadDiff,
} from '../../core/services/config-hot-reload';
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
import {
createOpenConfigSettingsWindowHandler,
@@ -28,6 +26,17 @@ export interface ConfigSettingsIpcChannels {
saveConfigSettingsPatch: string;
openConfigSettingsFile: string;
openConfigSettingsWindow: string;
getConfigSettingsAnkiDeckNames: string;
getConfigSettingsAnkiDeckFieldNames: string;
getConfigSettingsAnkiModelNames: string;
getConfigSettingsAnkiModelFieldNames: string;
}
export interface ConfigSettingsAnkiClient {
deckNames(): Promise<string[]>;
fieldNamesForDeck(deckName: string): Promise<string[]>;
modelNames(): Promise<string[]>;
modelFieldNames(modelName: string): Promise<string[]>;
}
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
@@ -37,12 +46,13 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
getConfig(): ResolvedConfig;
getWarnings(): ConfigValidationWarning[];
reloadConfigStrict(): ReloadConfigStrictResult;
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
getSettingsWindow(): TWindow | null;
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
openPath(path: string): Promise<string>;
defaultAnkiConnectUrl: string;
createAnkiClient(url: string): ConfigSettingsAnkiClient;
ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void;
@@ -111,7 +121,6 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
reloadConfigStrict: () => deps.reloadConfigStrict(),
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
});
@@ -142,6 +151,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
};
}
function getAnkiConnectUrl(draftUrl: unknown): string {
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
? draftUrl.trim()
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
}
async function getAnkiList(
draftUrl: unknown,
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
): Promise<ConfigSettingsAnkiListResult> {
try {
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
return { ok: true, values: await lookup(client) };
} catch (error) {
return {
ok: false,
values: [],
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
};
}
}
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
return {
ok: false,
values: [],
error,
};
}
function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
@@ -155,6 +194,26 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
return openError.length === 0;
});
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
getAnkiList(draftUrl, (client) => client.deckNames()),
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
(_event, deckName, draftUrl) =>
typeof deckName === 'string'
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(deckName))
: invalidAnkiListResult('Deck name is required.'),
);
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
getAnkiList(draftUrl, (client) => client.modelNames()),
);
deps.ipcMain.handle(
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
(_event, modelName, draftUrl) =>
typeof modelName === 'string'
? getAnkiList(draftUrl, (client) => client.modelFieldNames(modelName))
: invalidAnkiListResult('Note type is required.'),
);
}
return {
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
};
}
test('config settings save applies hot-reloadable diff live', () => {
test('config settings save returns hot-reloadable diff for watcher path', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
hotReloadFields: ['subtitleStyle'],
restartRequiredFields: [],
}),
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
getRestartRequiredSections: () => [],
});
@@ -62,7 +61,7 @@ test('config settings save applies hot-reloadable diff live', () => {
assert.equal(result.ok, true);
assert.match(written, /autoPauseVideoOnHover/);
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
assert.deepEqual(calls, ['write']);
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(result.restartRequiredFields, []);
});
@@ -95,7 +94,6 @@ test('config settings save returns restart-required sections without applying ho
hotReloadFields: [],
restartRequiredFields: ['mpv'],
}),
applyHotReload: () => calls.push('hot'),
getRestartRequiredSections: () => ['mpv launcher'],
});
@@ -130,9 +128,6 @@ test('config settings save restores previous file content when strict reload fai
classifyDiff: () => {
throw new Error('Should not classify invalid config.');
},
applyHotReload: () => {
throw new Error('Should not hot reload invalid config.');
},
getRestartRequiredSections: () => [],
});
+11 -10
View File
@@ -23,7 +23,6 @@ export interface ConfigSettingsSaveDeps {
deleteFile?(path: string): void;
reloadConfigStrict(): ReloadConfigStrictResult;
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
getRestartRequiredSections(restartRequiredFields: string[]): string[];
}
@@ -64,12 +63,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
deps.writeTextAtomically(configPath, candidate.content);
const reloadResult = deps.reloadConfigStrict();
if (!reloadResult.ok) {
if (hadExistingConfig) {
deps.writeTextAtomically(configPath, content);
} else if (deps.deleteFile) {
deps.deleteFile(configPath);
} else {
deps.writeTextAtomically(configPath, content);
try {
if (hadExistingConfig) {
deps.writeTextAtomically(configPath, content);
} else if (deps.deleteFile) {
deps.deleteFile(configPath);
} else {
deps.writeTextAtomically(configPath, content);
}
deps.reloadConfigStrict();
} catch {
// Best-effort rollback; preserve original reload error for caller.
}
return {
ok: false,
@@ -82,9 +86,6 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
}
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
if (diff.hotReloadFields.length > 0) {
deps.applyHotReload(diff, reloadResult.config);
}
return {
ok: true,
@@ -118,7 +118,6 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
preload: '/tmp/preload-settings.js',
},
});
-1
View File
@@ -79,7 +79,6 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
title: 'SubMiner Configuration',
resizable: true,
preloadPath: deps.preloadPath,
sandbox: false,
backgroundColor: '#24273a',
});
}