From 0298a066adb113dff24b55efa1d8c67ebbc92a1b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 17 May 2026 02:10:16 -0700 Subject: [PATCH] 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 --- changes/config-settings-controls.md | 4 + changes/known-word-color-config.md | 4 + config.example.jsonc | 12 +- docs-site/configuration.md | 19 +- docs-site/public/config.example.jsonc | 12 +- docs-site/subtitle-annotations.md | 8 +- src/anki-connect.test.ts | 75 +++ src/anki-connect.ts | 42 ++ src/config/config.test.ts | 18 +- .../definitions/defaults-integrations.ts | 2 - .../definitions/domain-registry.test.ts | 16 +- .../definitions/options-integrations.ts | 15 +- src/config/definitions/options-subtitle.ts | 12 + src/config/resolve/anki-connect.ts | 43 +- src/config/resolve/subtitle-domains.ts | 32 ++ src/config/resolve/subtitle-style.test.ts | 50 ++ src/config/settings/registry.test.ts | 92 ++-- src/config/settings/registry.ts | 221 +++++++- src/main.ts | 8 +- .../runtime/config-hot-reload-handlers.ts | 4 +- src/main/runtime/config-settings-runtime.ts | 71 ++- src/main/runtime/config-settings-save.test.ts | 9 +- src/main/runtime/config-settings-save.ts | 21 +- src/main/runtime/setup-window-factory.test.ts | 1 - src/main/runtime/setup-window-factory.ts | 1 - src/preload-settings.test.ts | 13 + src/preload-settings.ts | 19 + src/renderer/subtitle-render.ts | 4 +- src/settings/key-input.test.ts | 102 ++++ src/settings/key-input.ts | 217 ++++++++ src/settings/settings-anki-controls.ts | 473 ++++++++++++++++++ src/settings/settings-control-context.ts | 8 + src/settings/settings-control-dom.ts | 34 ++ src/settings/settings-controls.ts | 202 ++++++++ src/settings/settings-keybinding-controls.ts | 138 +++++ src/settings/settings-model.test.ts | 8 +- src/settings/settings-model.ts | 1 + src/settings/settings.ts | 226 +++------ src/settings/style.css | 194 ++++++- src/shared/ipc/contracts.ts | 4 + src/types/config.ts | 2 - src/types/runtime.ts | 5 +- src/types/settings.ts | 29 +- src/types/subtitle.ts | 2 + 44 files changed, 2152 insertions(+), 321 deletions(-) create mode 100644 changes/config-settings-controls.md create mode 100644 changes/known-word-color-config.md create mode 100644 src/settings/key-input.test.ts create mode 100644 src/settings/key-input.ts create mode 100644 src/settings/settings-anki-controls.ts create mode 100644 src/settings/settings-control-context.ts create mode 100644 src/settings/settings-control-dom.ts create mode 100644 src/settings/settings-controls.ts create mode 100644 src/settings/settings-keybinding-controls.ts diff --git a/changes/config-settings-controls.md b/changes/config-settings-controls.md new file mode 100644 index 00000000..ea797aac --- /dev/null +++ b/changes/config-settings-controls.md @@ -0,0 +1,4 @@ +type: changed +area: config + +- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers. diff --git a/changes/known-word-color-config.md b/changes/known-word-color-config.md new file mode 100644 index 00000000..016e6bd1 --- /dev/null +++ b/changes/known-word-color-config.md @@ -0,0 +1,4 @@ +type: changed +area: config + +- Config: Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with warnings. diff --git a/config.example.jsonc b/config.example.jsonc index 705fc2aa..e998ca87 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -381,8 +381,8 @@ "fontStyle": "normal", // Font style setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. - "nPlusOneColor": "#c6a0f6", // N plus one color setting. - "knownWordColor": "#a6da95", // Known word color setting. + "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. + "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "jlptColors": { "N1": "#ed8796", // N1 setting. "N2": "#f5a97f", // N2 setting. @@ -512,8 +512,7 @@ "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface - "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. - "color": "#a6da95" // Color used for known-word highlights. + "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -524,8 +523,7 @@ "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { - "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). - "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight. + "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). @@ -648,7 +646,7 @@ // ========================================== "discordPresence": { "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false - "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". + "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 7c675413..ff2070bf 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -67,7 +67,8 @@ On macOS, these validation warnings also open a native dialog with full details SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape: -- Viewing +- Appearance +- Behavior - Mining & Anki - Playback & Sources - Input @@ -75,13 +76,13 @@ SubMiner also includes a dedicated **Configuration** window from the tray menu, - Tracking & App - Advanced -Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. +Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes. The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies. Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available. -Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys. +Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, YouTube subtitle-generation settings, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, Jellyfin client identity/library defaults, and controller binding/profile internals that are edited in-app. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys. Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window. @@ -363,6 +364,8 @@ See `config.example.jsonc` for detailed configuration options. | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias | | `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | +| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | +| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | | `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) | @@ -370,8 +373,6 @@ See `config.example.jsonc` for detailed configuration options. | `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | | `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | | `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | -| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) | -| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) | | `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | | `secondary` | object | Override any of the above for secondary subtitles (optional) | @@ -963,11 +964,9 @@ This example is intentionally compact. The option table below documents availabl | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | | `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | -| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). | | `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | | `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | | `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). | -| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | | `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | | `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | | `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | @@ -1009,9 +1008,9 @@ Known-word cache policy: - Initial sync runs when the integration starts if the cache is missing or stale. - `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki. -- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists. +- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists. - `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`). -- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki. +- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki. - `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope. - Cache state is persisted to `known-words-cache.json` under the app `userData` directory. - The cache is automatically invalidated when the configured scope changes (for example, when deck changes). @@ -1255,7 +1254,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner | `directPlayContainers` | string[] | Container allowlist for direct play decisions | | `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | -Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window. +Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The configuration window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. - On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=` on launcher/app invocations when needed. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 705fc2aa..e998ca87 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -381,8 +381,8 @@ "fontStyle": "normal", // Font style setting. "backgroundColor": "transparent", // Background color setting. "backdropFilter": "blur(6px)", // Backdrop filter setting. - "nPlusOneColor": "#c6a0f6", // N plus one color setting. - "knownWordColor": "#a6da95", // Known word color setting. + "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. + "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "jlptColors": { "N1": "#ed8796", // N1 setting. "N2": "#f5a97f", // N2 setting. @@ -512,8 +512,7 @@ "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface - "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. - "color": "#a6da95" // Color used for known-word highlights. + "decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -524,8 +523,7 @@ "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { - "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). - "nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight. + "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). @@ -648,7 +646,7 @@ // ========================================== "discordPresence": { "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false - "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". + "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index d6ddc7f7..db1eb744 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -15,8 +15,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki 1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values. 2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval. 3. When a subtitle line appears, each token is checked against the cache. -4. If exactly one unknown word remains in the sentence, it is highlighted with `nPlusOneColor` (default: `#c6a0f6`). -5. Already-known tokens can optionally display in `knownWordColor` (default: `#a6da95`). +4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`). +5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`). **Key settings:** @@ -27,8 +27,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki | `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) | | `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) | | `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger | -| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word | -| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens | +| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word | +| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens | ::: tip Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large. diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index 19aa735b..14982924 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -48,3 +48,78 @@ test('AnkiConnectClient includes action name in retry logs', async () => { console.info = originalInfo; } }); + +test('AnkiConnectClient lists decks and note type fields', async () => { + const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { + client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; + }; + const calls: Array<{ action: string; params: unknown }> = []; + client.client = { + post: async (_url, body) => { + calls.push({ action: body.action, params: body.params }); + if (body.action === 'deckNames') { + return { data: { result: ['Core', 'Mining'], error: null } }; + } + if (body.action === 'modelNames') { + return { data: { result: ['Japanese sentences'], error: null } }; + } + if (body.action === 'modelFieldNames') { + return { data: { result: ['Expression', 'Sentence'], error: null } }; + } + return { data: { result: [], error: null } }; + }, + }; + + const typedClient = client as unknown as AnkiConnectClient; + + assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']); + assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']); + assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [ + 'Expression', + 'Sentence', + ]); + assert.deepEqual( + calls.map((call) => call.action), + ['deckNames', 'modelNames', 'modelFieldNames'], + ); +}); + +test('AnkiConnectClient derives field names from sampled notes in a deck', async () => { + const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { + client: { post: (url: string, body: { action: string; params: unknown }) => Promise }; + }; + const calls: Array<{ action: string; params: unknown }> = []; + client.client = { + post: async (_url, body) => { + calls.push({ action: body.action, params: body.params }); + if (body.action === 'findNotes') { + return { data: { result: [3, 1, 2], error: null } }; + } + if (body.action === 'notesInfo') { + return { + data: { + result: [ + { fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } }, + { fields: { Reading: { value: 'z' } } }, + ], + error: null, + }, + }; + } + return { data: { result: [], error: null } }; + }, + }; + + assert.deepEqual( + await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'), + ['Expression', 'Reading', 'Sentence'], + ); + assert.deepEqual(calls[0], { + action: 'findNotes', + params: { query: 'deck:"Mining \\"Current\\""' }, + }); + assert.deepEqual(calls[1], { + action: 'notesInfo', + params: { notes: [3, 1, 2] }, + }); +}); diff --git a/src/anki-connect.ts b/src/anki-connect.ts index ec5107be..1fe4cecf 100644 --- a/src/anki-connect.ts +++ b/src/anki-connect.ts @@ -156,6 +156,48 @@ export class AnkiConnectClient { return (result as number[]) || []; } + async deckNames(): Promise { + const result = await this.invoke('deckNames'); + return Array.isArray(result) + ? result.filter((value): value is string => typeof value === 'string').sort() + : []; + } + + async modelNames(): Promise { + const result = await this.invoke('modelNames'); + return Array.isArray(result) + ? result.filter((value): value is string => typeof value === 'string').sort() + : []; + } + + async modelFieldNames(modelName: string): Promise { + const result = await this.invoke('modelFieldNames', { modelName }); + return Array.isArray(result) + ? result.filter((value): value is string => typeof value === 'string').sort() + : []; + } + + async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise { + const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 }); + if (noteIds.length === 0) { + return []; + } + + const noteInfos = await this.notesInfo(noteIds.slice(0, sampleSize)); + const fields = new Set(); + for (const noteInfo of noteInfos) { + const noteFields = noteInfo.fields; + if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) { + continue; + } + for (const fieldName of Object.keys(noteFields)) { + fields.add(fieldName); + } + } + return [...fields].sort(); + } + async notesInfo(noteIds: number[]): Promise[]> { const result = await this.invoke('notesInfo', { notes: noteIds }); return (result as Record[]) || []; diff --git a/src/config/config.test.ts b/src/config/config.test.ts index fbdcf27e..caf9960f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -1846,7 +1846,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => { assert.equal(config.ankiConnect.knownWords.matchMode, 'surface'); }); -test('validates ankiConnect knownWords and n+1 color values', () => { +test('validates legacy ankiConnect knownWords and n+1 color values', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), @@ -1867,13 +1867,13 @@ test('validates ankiConnect knownWords and n+1 color values', () => { const config = service.getConfig(); const warnings = service.getWarnings(); - assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne); - assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color); + assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor); + assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color')); }); -test('accepts valid ankiConnect knownWords and n+1 color values', () => { +test('maps legacy ankiConnect knownWords and n+1 color values to subtitleStyle', () => { const dir = makeTempDir(); fs.writeFileSync( path.join(dir, 'config.jsonc'), @@ -1893,8 +1893,8 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => { const service = new ConfigService(dir); const config = service.getConfig(); - assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6'); - assert.equal(config.ankiConnect.knownWords.color, '#a6da95'); + assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6'); + assert.equal(config.subtitleStyle.knownWordColor, '#a6da95'); }); test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => { @@ -1926,7 +1926,7 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () Mining: ['Expression', 'Word', 'Reading', 'Word Reading'], 'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'], }); - assert.equal(config.ankiConnect.knownWords.color, '#a6da95'); + assert.equal(config.subtitleStyle.knownWordColor, '#a6da95'); assert.ok( warnings.some( (warning) => @@ -2280,9 +2280,9 @@ test('template generator includes known keys', () => { assert.match(output, /"characterDictionary":\s*\{/); assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"knownWords"\s*:\s*\{/); - assert.match(output, /"color": "#a6da95"/); + assert.match(output, /"knownWordColor": "#a6da95"/); + assert.match(output, /"nPlusOneColor": "#c6a0f6"/); assert.match(output, /"nPlusOne"\s*:\s*\{/); - assert.match(output, /"nPlusOne": "#c6a0f6"/); assert.match(output, /"minSentenceWords": 3/); assert.match(output, /auto-generated from src\/config\/definitions.ts/); assert.match( diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 33da67a5..9730d677 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -59,7 +59,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< addMinedWordsImmediately: true, matchMode: 'headword', decks: {}, - color: '#a6da95', }, behavior: { overwriteAudio: true, @@ -71,7 +70,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< }, nPlusOne: { minSentenceWords: 3, - nPlusOne: '#c6a0f6', }, metadata: { pattern: '[SubMiner] %f (%t)', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 6178d1c9..a588bb54 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -63,10 +63,8 @@ const UNDOCUMENTED_LEAVES: ReadonlySet = new Set([ 'subtitleStyle.jlptColors.N3', 'subtitleStyle.jlptColors.N4', 'subtitleStyle.jlptColors.N5', - 'subtitleStyle.knownWordColor', 'subtitleStyle.letterSpacing', 'subtitleStyle.lineHeight', - 'subtitleStyle.nPlusOneColor', 'subtitleStyle.secondary.backdropFilter', 'subtitleStyle.secondary.backgroundColor', 'subtitleStyle.secondary.fontColor', @@ -112,6 +110,20 @@ test('config option registry includes critical paths and has unique entries', () assert.equal(new Set(paths).size, paths.length); }); +test('known-word annotation color has one public config path', () => { + const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); + + assert.ok(leaves.includes('subtitleStyle.knownWordColor')); + assert.ok(!leaves.includes('ankiConnect.knownWords.color')); +}); + +test('n+1 annotation color has one public config path', () => { + const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); + + assert.ok(leaves.includes('subtitleStyle.nPlusOneColor')); + assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne')); +}); + test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => { const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path)); const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index dcc84e7a..d6f3320b 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -291,18 +291,6 @@ export function buildIntegrationConfigOptionRegistry( description: 'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.', }, - { - path: 'ankiConnect.nPlusOne.nPlusOne', - kind: 'string', - defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne, - description: 'Color used for the single N+1 target token highlight.', - }, - { - path: 'ankiConnect.knownWords.color', - kind: 'string', - defaultValue: defaultConfig.ankiConnect.knownWords.color, - description: 'Color used for known-word highlights.', - }, { path: 'ankiConnect.isKiku.fieldGrouping', kind: 'enum', @@ -567,7 +555,8 @@ export function buildIntegrationConfigOptionRegistry( }, { path: 'discordPresence.presenceStyle', - kind: 'string', + kind: 'enum', + enumValues: ['default', 'meme', 'japanese', 'minimal'], defaultValue: defaultConfig.discordPresence.presenceStyle, description: 'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index f10d54a0..8fd500a1 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -69,6 +69,18 @@ export function buildSubtitleConfigOptionRegistry( description: 'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.', }, + { + path: 'subtitleStyle.knownWordColor', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.knownWordColor, + description: 'Color used for known-word subtitle highlights.', + }, + { + path: 'subtitleStyle.nPlusOneColor', + kind: 'string', + defaultValue: defaultConfig.subtitleStyle.nPlusOneColor, + description: 'Color used for the single N+1 target token subtitle highlight.', + }, { path: 'subtitleStyle.frequencyDictionary.enabled', kind: 'boolean', diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index c46b18ff..ee8549ab 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -946,47 +946,68 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { } } + const rawSubtitleStyle = isObject(context.src.subtitleStyle) + ? (context.src.subtitleStyle as Record) + : {}; + const hasCanonicalNPlusOneColor = rawSubtitleStyle.nPlusOneColor !== undefined; + const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined; + const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne); if (nPlusOneHighlightColor !== undefined) { - context.resolved.ankiConnect.nPlusOne.nPlusOne = nPlusOneHighlightColor; + if (!hasCanonicalNPlusOneColor) { + context.resolved.subtitleStyle.nPlusOneColor = nPlusOneHighlightColor; + } + context.warn( + 'ankiConnect.nPlusOne.nPlusOne', + nPlusOneConfig.nPlusOne, + context.resolved.subtitleStyle.nPlusOneColor, + 'Legacy key is deprecated; use subtitleStyle.nPlusOneColor', + ); } else if (nPlusOneConfig.nPlusOne !== undefined) { context.warn( 'ankiConnect.nPlusOne.nPlusOne', nPlusOneConfig.nPlusOne, - context.resolved.ankiConnect.nPlusOne.nPlusOne, + context.resolved.subtitleStyle.nPlusOneColor, 'Expected a hex color value.', ); - context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne; } const knownWordsColor = asColor(knownWordsConfig.color); const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord); if (knownWordsColor !== undefined) { - context.resolved.ankiConnect.knownWords.color = knownWordsColor; + if (!hasCanonicalKnownWordColor) { + context.resolved.subtitleStyle.knownWordColor = knownWordsColor; + } + context.warn( + 'ankiConnect.knownWords.color', + knownWordsConfig.color, + context.resolved.subtitleStyle.knownWordColor, + 'Legacy key is deprecated; use subtitleStyle.knownWordColor', + ); } else if (knownWordsConfig.color !== undefined) { context.warn( 'ankiConnect.knownWords.color', knownWordsConfig.color, - context.resolved.ankiConnect.knownWords.color, + context.resolved.subtitleStyle.knownWordColor, 'Expected a hex color value.', ); - context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color; } else if (legacyNPlusOneKnownWordColor !== undefined) { - context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor; + if (!hasCanonicalKnownWordColor) { + context.resolved.subtitleStyle.knownWordColor = legacyNPlusOneKnownWordColor; + } context.warn( 'ankiConnect.nPlusOne.knownWord', nPlusOneConfig.knownWord, - DEFAULT_CONFIG.ankiConnect.knownWords.color, - 'Legacy key is deprecated; use ankiConnect.knownWords.color', + context.resolved.subtitleStyle.knownWordColor, + 'Legacy key is deprecated; use subtitleStyle.knownWordColor', ); } else if (nPlusOneConfig.knownWord !== undefined) { context.warn( 'ankiConnect.nPlusOne.knownWord', nPlusOneConfig.knownWord, - context.resolved.ankiConnect.knownWords.color, + context.resolved.subtitleStyle.knownWordColor, 'Expected a hex color value.', ); - context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color; } if ( diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index c1ed26e6..bd18a8f7 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -157,6 +157,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { resolved.subtitleStyle.hoverTokenBackgroundColor; const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled; const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor; + const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor; + const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor; const fallbackFrequencyDictionary = { ...resolved.subtitleStyle.frequencyDictionary, }; @@ -333,6 +335,36 @@ export function applySubtitleDomainConfig(context: ResolveContext): void { ); } + const knownWordColor = asColor( + (src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor, + ); + if (knownWordColor !== undefined) { + resolved.subtitleStyle.knownWordColor = knownWordColor; + } else if ((src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor !== undefined) { + resolved.subtitleStyle.knownWordColor = fallbackSubtitleStyleKnownWordColor; + warn( + 'subtitleStyle.knownWordColor', + (src.subtitleStyle as { knownWordColor?: unknown }).knownWordColor, + resolved.subtitleStyle.knownWordColor, + 'Expected hex color.', + ); + } + + const nPlusOneColor = asColor( + (src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor, + ); + if (nPlusOneColor !== undefined) { + resolved.subtitleStyle.nPlusOneColor = nPlusOneColor; + } else if ((src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor !== undefined) { + resolved.subtitleStyle.nPlusOneColor = fallbackSubtitleStyleNPlusOneColor; + warn( + 'subtitleStyle.nPlusOneColor', + (src.subtitleStyle as { nPlusOneColor?: unknown }).nPlusOneColor, + resolved.subtitleStyle.nPlusOneColor, + 'Expected hex color.', + ); + } + const frequencyDictionary = isObject( (src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary, ) diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts index 311021a2..b2522642 100644 --- a/src/config/resolve/subtitle-style.test.ts +++ b/src/config/resolve/subtitle-style.test.ts @@ -155,6 +155,56 @@ test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', ( ); }); +test('subtitleStyle knownWordColor accepts valid values and warns on invalid', () => { + const valid = createResolveContext({ + subtitleStyle: { + knownWordColor: '#ed8796', + }, + }); + applySubtitleDomainConfig(valid.context); + assert.equal(valid.context.resolved.subtitleStyle.knownWordColor, '#ed8796'); + + const invalid = createResolveContext({ + subtitleStyle: { + knownWordColor: 'pink', + }, + }); + applySubtitleDomainConfig(invalid.context); + assert.equal(invalid.context.resolved.subtitleStyle.knownWordColor, '#a6da95'); + assert.ok( + invalid.warnings.some( + (warning) => + warning.path === 'subtitleStyle.knownWordColor' && + warning.message === 'Expected hex color.', + ), + ); +}); + +test('subtitleStyle nPlusOneColor accepts valid values and warns on invalid', () => { + const valid = createResolveContext({ + subtitleStyle: { + nPlusOneColor: '#ed8796', + }, + }); + applySubtitleDomainConfig(valid.context); + assert.equal(valid.context.resolved.subtitleStyle.nPlusOneColor, '#ed8796'); + + const invalid = createResolveContext({ + subtitleStyle: { + nPlusOneColor: 'pink', + }, + }); + applySubtitleDomainConfig(invalid.context); + assert.equal(invalid.context.resolved.subtitleStyle.nPlusOneColor, '#c6a0f6'); + assert.ok( + invalid.warnings.some( + (warning) => + warning.path === 'subtitleStyle.nPlusOneColor' && + warning.message === 'Expected hex color.', + ), + ); +}); + test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 8a1399cf..32b7cbdc 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -1,39 +1,75 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_CONFIG } from '../definitions'; -import { - buildConfigSettingsRegistry, - getConfigSettingsCoverage, - LEGACY_HIDDEN_CONFIG_PATHS, -} from './registry'; +import { buildConfigSettingsRegistry } from './registry'; -test('config settings registry places hover pause under viewing playback behavior', () => { - const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); - const hoverPause = fields.find( - (field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover', - ); +const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); - assert.ok(hoverPause); - assert.equal(hoverPause.category, 'viewing'); - assert.equal(hoverPause.section, 'Playback pause behavior'); - assert.equal(hoverPause.control, 'boolean'); +function field(path: string) { + const match = fields.find((candidate) => candidate.configPath === path); + assert.ok(match, `missing settings field: ${path}`); + return match; +} + +test('settings registry splits viewing into appearance and behavior categories', () => { + assert.equal(field('subtitleStyle.fontSize').category, 'appearance'); + assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior'); + assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior'); + assert.equal(field('secondarySub.defaultMode').category, 'behavior'); + assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position'); }); -test('config settings registry hides legacy and ignored paths from normal fields', () => { - const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); - const visiblePaths = new Set( - fields.filter((field) => !field.legacyHidden).map((field) => field.configPath), +test('settings registry groups annotation display fields by config group', () => { + assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display'); + assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words'); + assert.equal(field('subtitleStyle.knownWordColor').subsection, 'Known Words'); + assert.equal(field('subtitleStyle.nPlusOneColor').subsection, 'N+1'); + assert.equal(field('subtitleStyle.enableJlpt').subsection, 'JLPT'); + assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color'); +}); + +test('settings registry exposes specialized controls for config-assisted inputs', () => { + assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks'); + assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type'); + assert.equal(field('ankiConnect.fields.word').control, 'anki-field'); + assert.equal(field('keybindings').control, 'mpv-keybindings'); + assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut'); + assert.equal(field('subtitleSidebar.toggleKey').control, 'key-code'); + assert.equal(field('stats.toggleKey').control, 'key-code'); + assert.equal(field('discordPresence.presenceStyle').control, 'select'); +}); + +test('settings registry puts feature toggles first, then other toggles alphabetically', () => { + const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect'); + assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled'); + assert.ok( + ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.enabled') < + ankiConnect.findIndex((candidate) => candidate.configPath === 'ankiConnect.pollingRate'), ); - for (const path of LEGACY_HIDDEN_CONFIG_PATHS) { - assert.equal(visiblePaths.has(path), false, path); + const kikuLapis = fields.filter( + (candidate) => candidate.section === 'Kiku Features And Lapis Features', + ); + assert.deepEqual( + kikuLapis.slice(0, 2).map((candidate) => candidate.configPath), + ['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'], + ); +}); + +test('settings registry hides app-managed and inactive config surfaces', () => { + const paths = new Set(fields.map((candidate) => candidate.configPath)); + for (const hiddenPath of [ + 'controller.bindings', + 'controller.preferredGamepadId', + 'controller.preferredGamepadLabel', + 'controller.profiles', + 'youtubeSubgen.whisperBin', + 'jellyfin.clientVersion', + 'jellyfin.defaultLibraryId', + 'jellyfin.deviceId', + 'jellyfin.clientName', + ]) { + assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`); } - assert.equal(visiblePaths.has('controller.buttonIndices'), false); -}); - -test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => { - const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG); - const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields); - - assert.deepEqual(coverage.uncoveredDefaultPaths, []); + assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary'); }); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 1d1ea393..27da69d7 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -46,20 +46,30 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'ankiConnect.nPlusOne.matchMode', 'ankiConnect.nPlusOne.decks', 'ankiConnect.nPlusOne.knownWord', + 'ankiConnect.nPlusOne.nPlusOne', + 'ankiConnect.knownWords.color', 'ankiConnect.behavior.nPlusOneHighlightEnabled', 'ankiConnect.behavior.nPlusOneRefreshMinutes', 'ankiConnect.behavior.nPlusOneMatchMode', 'ankiConnect.isLapis.sentenceCardSentenceField', 'ankiConnect.isLapis.sentenceCardAudioField', + 'controller.bindings', + 'controller.preferredGamepadId', + 'controller.preferredGamepadLabel', + 'controller.profiles', 'youtubeSubgen.primarySubLanguages', 'anilist.characterDictionary.refreshTtlHours', 'anilist.characterDictionary.evictionPolicy', 'jellyfin.accessToken', 'jellyfin.userId', + 'jellyfin.clientName', + 'jellyfin.clientVersion', + 'jellyfin.defaultLibraryId', + 'jellyfin.deviceId', 'controller.buttonIndices', ] as const; -const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const; +const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const; const JSON_OBJECT_FIELDS = new Set([ 'keybindings', @@ -75,12 +85,79 @@ const COLOR_SUFFIXES = new Set([ 'color', 'backgroundColor', 'singleColor', - 'knownWordColor', 'nPlusOne', ]); const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry])); +const CATEGORY_ORDER: ConfigSettingsCategory[] = [ + 'appearance', + 'behavior', + 'mining-anki', + 'playback-sources', + 'input', + 'integrations', + 'tracking-app', + 'advanced', +]; + +const SECTION_ORDER = new Map( + [ + 'Annotation Display', + 'Primary Subtitle Appearance', + 'Secondary Subtitle Appearance', + 'Subtitle Sidebar Appearance', + 'Playback Pause Behavior', + 'Subtitle Behavior', + 'Subtitle Sidebar Behavior', + 'Note Fields', + 'Media Capture', + 'Kiku Features And Lapis Features', + 'Anki AI', + 'AnkiConnect Proxy', + 'AnkiConnect', + 'MPV Keybindings', + 'Overlay Shortcuts', + 'Controller', + 'Character Dictionary', + ].map((section, index) => [section, index]), +); + +const PATH_ORDER = new Map( + [ + 'ankiConnect.enabled', + 'ankiConnect.proxy.enabled', + 'ankiConnect.isLapis.enabled', + 'ankiConnect.isKiku.enabled', + ].map((path, index) => [path, index]), +); + +const SUBSECTION_ORDER = new Map( + ['Known Words', 'N+1', 'JLPT', 'Frequency Dictionary', 'Character Names'].map( + (subsection, index) => [subsection, index], + ), +); + +const LABEL_OVERRIDES: Record = { + 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', + 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', + 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', + 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', + 'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode', + 'subtitlePosition.yPercent': 'Subtitle Position', +}; + +const DESCRIPTION_OVERRIDES: Record = { + 'ankiConnect.pollingRate': + 'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.', + 'ankiConnect.isKiku.enabled': + 'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.', + 'ankiConnect.isLapis.enabled': + 'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.', + 'ankiConnect.isLapis.sentenceCardModel': + 'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.', +}; + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -119,6 +196,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] { } function humanizePath(path: string): string { + const override = LABEL_OVERRIDES[path]; + if (override) { + return override; + } const key = path.split('.').at(-1) ?? path; const spaced = key .replace(/_/g, ' ') @@ -138,7 +219,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' || path === 'subtitleSidebar.pauseVideoOnHover' ) { - return { category: 'viewing', section: 'Playback pause behavior' }; + return { category: 'behavior', section: 'Playback Pause Behavior' }; } if ( path.startsWith('ankiConnect.knownWords.') || @@ -146,37 +227,51 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s path.startsWith('subtitleStyle.frequencyDictionary.') || path.startsWith('subtitleStyle.jlptColors.') || path === 'subtitleStyle.enableJlpt' || + path === 'subtitleStyle.knownWordColor' || + path === 'subtitleStyle.nPlusOneColor' || path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor' ) { - return { category: 'viewing', section: 'Annotation display' }; + return { category: 'appearance', section: 'Annotation Display' }; } if (path.startsWith('subtitleStyle.secondary.')) { - return { category: 'viewing', section: 'Secondary subtitle appearance' }; + return { category: 'appearance', section: 'Secondary Subtitle Appearance' }; + } + if (path === 'subtitleStyle.primaryDefaultMode') { + return { category: 'behavior', section: 'Subtitle Behavior' }; } if (path.startsWith('subtitleStyle.')) { - return { category: 'viewing', section: 'Primary subtitle appearance' }; + return { category: 'appearance', section: 'Primary Subtitle Appearance' }; } if (path.startsWith('subtitleSidebar.')) { - return { category: 'viewing', section: 'Subtitle sidebar' }; + const sidebarBehaviorPaths = new Set([ + 'subtitleSidebar.enabled', + 'subtitleSidebar.autoOpen', + 'subtitleSidebar.autoScroll', + 'subtitleSidebar.layout', + 'subtitleSidebar.toggleKey', + ]); + return sidebarBehaviorPaths.has(path) + ? { category: 'behavior', section: 'Subtitle Sidebar Behavior' } + : { category: 'appearance', section: 'Subtitle Sidebar Appearance' }; } if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) { - return { category: 'viewing', section: 'Subtitle behavior' }; + return { category: 'behavior', section: 'Subtitle Behavior' }; } if (path.startsWith('ankiConnect.fields.')) { - return { category: 'mining-anki', section: 'Note fields' }; + return { category: 'mining-anki', section: 'Note Fields' }; } if (path.startsWith('ankiConnect.media.')) { - return { category: 'mining-anki', section: 'Media capture' }; + return { category: 'mining-anki', section: 'Media Capture' }; } if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) { - return { category: 'mining-anki', section: 'Kiku and Lapis' }; + return { category: 'mining-anki', section: 'Kiku Features And Lapis Features' }; } if (path.startsWith('ankiConnect.ai.')) { return { category: 'mining-anki', section: 'Anki AI' }; } if (path.startsWith('ankiConnect.proxy.')) { - return { category: 'mining-anki', section: 'AnkiConnect proxy' }; + return { category: 'mining-anki', section: 'AnkiConnect Proxy' }; } if (path.startsWith('ankiConnect.')) { return { category: 'mining-anki', section: 'AnkiConnect' }; @@ -191,17 +286,16 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s return { category: 'playback-sources', section: topSection(path) }; } if (path.startsWith('shortcuts.')) { - return { category: 'input', section: 'Overlay shortcuts' }; + return { category: 'input', section: 'Overlay Shortcuts' }; } if (path === 'keybindings') { - return { category: 'input', section: 'MPV keybindings' }; + return { category: 'input', section: 'MPV Keybindings' }; } if (path.startsWith('controller.')) { return { category: 'input', section: 'Controller' }; } if ( path.startsWith('ai.') || - path.startsWith('anilist.') || path.startsWith('yomitan.') || path.startsWith('jellyfin.') || path.startsWith('discordPresence.') || @@ -211,6 +305,12 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s ) { return { category: 'integrations', section: topSection(path) }; } + if (path.startsWith('anilist.characterDictionary.')) { + return { category: 'integrations', section: 'Character Dictionary' }; + } + if (path.startsWith('anilist.')) { + return { category: 'integrations', section: 'AniList' }; + } if ( path.startsWith('immersionTracking.') || path.startsWith('stats.') || @@ -252,6 +352,21 @@ function topSection(path: string): string { function controlForPath(path: string, value: unknown): ConfigSettingsControl { if (SECRET_PATHS.has(path)) return 'secret'; + if (path === 'keybindings') return 'mpv-keybindings'; + if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks'; + if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type'; + if (path.startsWith('ankiConnect.fields.')) return 'anki-field'; + if (path.startsWith('shortcuts.')) + return path.endsWith('multiCopyTimeoutMs') ? 'number' : 'keyboard-shortcut'; + if ( + path === 'subtitleSidebar.toggleKey' || + path === 'stats.toggleKey' || + path === 'stats.markWatchedKey' + ) { + return 'key-code'; + } + if (path.startsWith('subtitleStyle.jlptColors.')) return 'color'; + if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list'; if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select'; if (JSON_OBJECT_FIELDS.has(path)) return 'json'; if (Array.isArray(value)) return 'string-list'; @@ -266,6 +381,70 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl { return 'json'; } +function subsectionForPath(path: string): string | undefined { + if (path.startsWith('ankiConnect.knownWords.')) return 'Known Words'; + if (path.startsWith('ankiConnect.nPlusOne.')) return 'N+1'; + if (path === 'subtitleStyle.knownWordColor') return 'Known Words'; + if (path === 'subtitleStyle.nPlusOneColor') return 'N+1'; + if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) { + return 'JLPT'; + } + if (path.startsWith('subtitleStyle.frequencyDictionary.')) return 'Frequency Dictionary'; + if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') { + return 'Character Names'; + } + return undefined; +} + +function isFeatureToggle(field: ConfigSettingsField): boolean { + if (field.control !== 'boolean') return false; + const leaf = field.configPath.split('.').at(-1) ?? field.configPath; + return ( + leaf === 'enabled' || + leaf.startsWith('enable') || + leaf.endsWith('Enabled') || + field.label.startsWith('Enable ') + ); +} + +function fieldTypeRank(field: ConfigSettingsField): number { + if (field.control !== 'boolean') return 2; + return isFeatureToggle(field) ? 0 : 1; +} + +function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number { + const category = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category); + if (category !== 0) return category; + + const section = + (SECTION_ORDER.get(a.section) ?? Number.MAX_SAFE_INTEGER) - + (SECTION_ORDER.get(b.section) ?? Number.MAX_SAFE_INTEGER); + if (section !== 0) return section; + + const sectionName = a.section.localeCompare(b.section); + if (sectionName !== 0) return sectionName; + + const subsection = + (SUBSECTION_ORDER.get(a.subsection ?? '') ?? Number.MAX_SAFE_INTEGER) - + (SUBSECTION_ORDER.get(b.subsection ?? '') ?? Number.MAX_SAFE_INTEGER); + if (subsection !== 0) return subsection; + + const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? ''); + if (subsectionName !== 0) return subsectionName; + + const type = fieldTypeRank(a) - fieldTypeRank(b); + if (type !== 0) return type; + + const pathOrder = + (PATH_ORDER.get(a.configPath) ?? Number.MAX_SAFE_INTEGER) - + (PATH_ORDER.get(b.configPath) ?? Number.MAX_SAFE_INTEGER); + if (pathOrder !== 0) return pathOrder; + + const label = a.label.localeCompare(b.label); + if (label !== 0) return label; + return a.configPath.localeCompare(b.configPath); +} + function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { if ( path === 'keybindings' || @@ -283,13 +462,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { function fieldForLeaf(leaf: Leaf): ConfigSettingsField { const option = OPTION_BY_PATH.get(leaf.path); const { category, section } = categoryAndSection(leaf.path); + const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description; return { id: leaf.path, label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path), - description: option?.description ?? `${humanizePath(leaf.path)} setting.`, + description: description ?? `${humanizePath(leaf.path)} setting.`, configPath: leaf.path, category, section, + ...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}), control: controlForPath(leaf.path, leaf.value), defaultValue: leaf.value, ...(option?.enumValues ? { enumValues: option.enumValues } : {}), @@ -306,13 +487,7 @@ export function buildConfigSettingsRegistry( defaultConfig: ResolvedConfig = DEFAULT_CONFIG, ): ConfigSettingsField[] { const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path)); - return leaves.map(fieldForLeaf).sort((a, b) => { - const category = a.category.localeCompare(b.category); - if (category !== 0) return category; - const section = a.section.localeCompare(b.section); - if (section !== 0) return section; - return a.configPath.localeCompare(b.configPath); - }); + return leaves.map(fieldForLeaf).sort(compareFields); } export function getConfigSettingsCoverage( diff --git a/src/main.ts b/src/main.ts index c2d16bf1..f58673df 100644 --- a/src/main.ts +++ b/src/main.ts @@ -139,6 +139,7 @@ import { } from './cli/args'; import { printHelp } from './cli/help'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; +import { AnkiConnectClient } from './anki-connect'; import { getStartupModeFlags, shouldRefreshAnilistOnConfigReload, @@ -679,8 +680,8 @@ const texthookerService = new Texthooker(() => { config.subtitleStyle.enableJlpt, ), characterDictionaryEnabled, - knownWordColor: config.ankiConnect.knownWords.color, - nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne, + knownWordColor: config.subtitleStyle.knownWordColor, + nPlusOneColor: config.subtitleStyle.nPlusOneColor, nameMatchColor: config.subtitleStyle.nameMatchColor, hoverTokenColor: config.subtitleStyle.hoverTokenColor, hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor, @@ -1806,7 +1807,8 @@ const configSettingsRuntime = createConfigSettingsRuntime({ getConfig: () => configService.getConfig(), getWarnings: () => configService.getWarnings(), reloadConfigStrict: () => configService.reloadConfigStrict(), - applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config), + defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url, + createAnkiClient: (url) => new AnkiConnectClient(url), getSettingsWindow: () => appState.configSettingsWindow, setSettingsWindow: (window) => { appState.configSettingsWindow = window as BrowserWindow | null; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index aef0d3bc..565f7b71 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -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, diff --git a/src/main/runtime/config-settings-runtime.ts b/src/main/runtime/config-settings-runtime.ts index 8a38d6de..111bc3c6 100644 --- a/src/main/runtime/config-settings-runtime.ts +++ b/src/main/runtime/config-settings-runtime.ts @@ -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; + fieldNamesForDeck(deckName: string): Promise; + modelNames(): Promise; + modelFieldNames(modelName: string): Promise; } export interface ConfigSettingsRuntimeDeps { @@ -37,12 +46,13 @@ export interface ConfigSettingsRuntimeDeps; + defaultAnkiConnectUrl: string; + createAnkiClient(url: string): ConfigSettingsAnkiClient; ipcMain: ConfigSettingsIpcMainLike; ipcChannels: ConfigSettingsIpcChannels; log?: (message: string) => void; @@ -111,7 +121,6 @@ export function createConfigSettingsRuntime 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 0 + ? draftUrl.trim() + : deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl; + } + + async function getAnkiList( + draftUrl: unknown, + lookup: (client: ConfigSettingsAnkiClient) => Promise, + ): Promise { + 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 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 { diff --git a/src/main/runtime/config-settings-save.test.ts b/src/main/runtime/config-settings-save.test.ts index 626be3db..6b7d8418 100644 --- a/src/main/runtime/config-settings-save.test.ts +++ b/src/main/runtime/config-settings-save.test.ts @@ -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: () => [], }); diff --git a/src/main/runtime/config-settings-save.ts b/src/main/runtime/config-settings-save.ts index ed257e03..1a9ac004 100644 --- a/src/main/runtime/config-settings-save.ts +++ b/src/main/runtime/config-settings-save.ts @@ -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, diff --git a/src/main/runtime/setup-window-factory.test.ts b/src/main/runtime/setup-window-factory.test.ts index 8f6f02af..188ebb11 100644 --- a/src/main/runtime/setup-window-factory.test.ts +++ b/src/main/runtime/setup-window-factory.test.ts @@ -101,7 +101,6 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind webPreferences: { nodeIntegration: false, contextIsolation: true, - sandbox: false, preload: '/tmp/preload-settings.js', }, }); diff --git a/src/main/runtime/setup-window-factory.ts b/src/main/runtime/setup-window-factory.ts index 22a45986..6e0fbec1 100644 --- a/src/main/runtime/setup-window-factory.ts +++ b/src/main/runtime/setup-window-factory.ts @@ -77,7 +77,6 @@ export function createCreateConfigSettingsWindowHandler(deps: { title: 'SubMiner Configuration', resizable: true, preloadPath: deps.preloadPath, - sandbox: false, backgroundColor: '#24273a', }); } diff --git a/src/preload-settings.test.ts b/src/preload-settings.test.ts index 7f4ac860..4ddb2771 100644 --- a/src/preload-settings.test.ts +++ b/src/preload-settings.test.ts @@ -8,3 +8,16 @@ test('settings preload stays sandbox-compatible by avoiding local runtime import assert.doesNotMatch(source, /from\s+['"]\.\/shared\/ipc\/contracts(?:\.(?:js|ts))?['"]/); }); + +test('settings preload exposes Anki lookup helpers', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload-settings.ts'), 'utf8'); + + for (const method of [ + 'getAnkiDeckNames', + 'getAnkiDeckFieldNames', + 'getAnkiModelNames', + 'getAnkiModelFieldNames', + ]) { + assert.match(source, new RegExp(`${method}:`)); + } +}); diff --git a/src/preload-settings.ts b/src/preload-settings.ts index c587fdd5..46867782 100644 --- a/src/preload-settings.ts +++ b/src/preload-settings.ts @@ -1,5 +1,6 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { + ConfigSettingsAnkiListResult, ConfigSettingsAPI, ConfigSettingsPatch, ConfigSettingsSaveResult, @@ -11,6 +12,10 @@ const SETTINGS_IPC_CHANNELS = { savePatch: 'config:save-settings-patch', openFile: 'config:open-settings-file', openWindow: 'config:open-settings-window', + getAnkiDeckNames: 'config-settings:anki-deck-names', + getAnkiDeckFieldNames: 'config-settings:anki-deck-field-names', + getAnkiModelNames: 'config-settings:anki-model-names', + getAnkiModelFieldNames: 'config-settings:anki-model-field-names', } as const; const configSettingsAPI: ConfigSettingsAPI = { @@ -20,6 +25,20 @@ const configSettingsAPI: ConfigSettingsAPI = { ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.savePatch, patch), openSettingsFile: (): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openFile), openSettingsWindow: (): Promise => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openWindow), + getAnkiDeckNames: (draftUrl?: string): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckNames, draftUrl), + getAnkiDeckFieldNames: ( + deckName: string, + draftUrl?: string, + ): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckFieldNames, deckName, draftUrl), + getAnkiModelNames: (draftUrl?: string): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelNames, draftUrl), + getAnkiModelFieldNames: ( + modelName: string, + draftUrl?: string, + ): Promise => + ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl), }; contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI); diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 3836dd52..f913bc36 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -3,7 +3,7 @@ import type { PrimarySubMode, SecondarySubMode, SubtitleData, - SubtitleStyleConfig, + SubtitleRendererStyleConfig, } from '../types'; import type { RendererContext } from './context'; @@ -635,7 +635,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`); } - function applySubtitleStyle(style: SubtitleStyleConfig | null): void { + function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void { if (!style) return; const styleDeclarations = style as Record; diff --git a/src/settings/key-input.test.ts b/src/settings/key-input.test.ts new file mode 100644 index 00000000..a74b43dd --- /dev/null +++ b/src/settings/key-input.test.ts @@ -0,0 +1,102 @@ +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('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, [])), + [], + ); +}); diff --git a/src/settings/key-input.ts b/src/settings/key-input.ts new file mode 100644 index 00000000..1fb2f330 --- /dev/null +++ b/src/settings/key-input.ts @@ -0,0 +1,217 @@ +import type { Keybinding } from '../types/runtime'; + +export type KeyInputMode = 'accelerator' | 'dom-code' | 'code'; + +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 = { + 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', +}; + +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; +} + +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 === '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(); + + 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; +} diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts new file mode 100644 index 00000000..29ed6a49 --- /dev/null +++ b/src/settings/settings-anki-controls.ts @@ -0,0 +1,473 @@ +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; + deckFieldNamesLoading: Set; + deckFieldNamesErrors: Map; + modelNames: string[] | null; + modelNamesLoading: boolean; + modelNamesError: string | null; + modelFieldNames: Map; + modelFieldNamesLoading: Set; + modelFieldNamesErrors: Map; + noteFieldModelName: string; + ankiConnectUrl: string; +} = { + deckNames: null, + deckNamesLoading: false, + deckNamesError: null, + deckFieldNames: new Map(), + deckFieldNamesLoading: new Set(), + deckFieldNamesErrors: new Map(), + modelNames: null, + modelNamesLoading: false, + modelNamesError: null, + modelFieldNames: new Map(), + modelFieldNamesLoading: new Set(), + modelFieldNamesErrors: new Map(), + noteFieldModelName: '', + ankiConnectUrl: '', +}; + +let requestRender = (): void => undefined; + +export function configureAnkiControls(options: { requestRender: () => void }): void { + requestRender = options.requestRender; +} + +export function initializeAnkiControls(values: Record): void { + const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel']; + if (!state.noteFieldModelName && typeof configuredNoteType === 'string') { + state.noteFieldModelName = configuredNoteType; + } +} + +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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + const decks: Record = {}; + 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, +): void { + const next: Record = {}; + 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 syncAnkiConnectUrl(draftUrl: string | undefined): void { + const nextUrl = draftUrl ?? ''; + if (state.ankiConnectUrl === nextUrl) { + return; + } + state.ankiConnectUrl = nextUrl; + state.deckNames = null; + state.deckNamesLoading = false; + state.deckNamesError = null; + state.deckFieldNames.clear(); + state.deckFieldNamesLoading.clear(); + state.deckFieldNamesErrors.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 { + syncAnkiConnectUrl(draftUrl); + if (state.deckNames || state.deckNamesLoading) return; + state.deckNamesLoading = true; + try { + const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl); + 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) { + state.deckNames = []; + state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.'; + } finally { + state.deckNamesLoading = false; + requestRender(); + } +} + +async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise { + syncAnkiConnectUrl(draftUrl); + if ( + !deckName || + state.deckFieldNames.has(deckName) || + state.deckFieldNamesLoading.has(deckName) + ) { + return; + } + state.deckFieldNamesLoading.add(deckName); + try { + const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl); + 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) { + state.deckFieldNames.set(deckName, []); + state.deckFieldNamesErrors.set( + deckName, + error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`, + ); + } finally { + state.deckFieldNamesLoading.delete(deckName); + requestRender(); + } +} + +async function loadAnkiModelNames(draftUrl?: string): Promise { + syncAnkiConnectUrl(draftUrl); + if (state.modelNames || state.modelNamesLoading) return; + state.modelNamesLoading = true; + try { + const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl); + if (result.ok) { + state.modelNames = uniqueSorted(result.values); + state.modelNamesError = null; + if (!state.noteFieldModelName && state.modelNames[0]) { + state.noteFieldModelName = state.modelNames[0]; + } + } else { + state.modelNames = []; + state.modelNamesError = result.error ?? 'Failed to load Anki note types.'; + } + } catch (error) { + state.modelNames = []; + state.modelNamesError = + error instanceof Error ? error.message : 'Failed to load Anki note types.'; + } finally { + state.modelNamesLoading = false; + requestRender(); + } +} + +async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Promise { + syncAnkiConnectUrl(draftUrl); + if ( + !modelName || + state.modelFieldNames.has(modelName) || + state.modelFieldNamesLoading.has(modelName) + ) { + return; + } + state.modelFieldNamesLoading.add(modelName); + try { + const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl); + 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) { + state.modelFieldNames.set(modelName, []); + state.modelFieldNamesErrors.set( + modelName, + error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`, + ); + } finally { + state.modelFieldNamesLoading.delete(modelName); + requestRender(); + } +} + +export function renderAnkiNoteTypeInput( + context: SettingsControlContext, + field: ConfigSettingsField, +): HTMLElement { + const draftUrl = getDraftAnkiConnectUrl(context); + void loadAnkiModelNames(draftUrl); + 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); + 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); + 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; + 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 deckSelect = createElement('select', 'config-input') as HTMLSelectElement; + for (const candidateDeck of uniqueSorted([...deckNames, deckName])) { + addOption(deckSelect, candidateDeck); + } + deckSelect.value = deckName; + deckSelect.addEventListener('change', () => { + const nextDecks = normalizeKnownWordsDecks(context.valueForField(field)); + const fields = nextDecks[deckName] ?? []; + delete nextDecks[deckName]; + nextDecks[deckSelect.value] = fields; + setKnownWordsDecks(context, field.configPath, nextDecks); + requestRender(); + }); + + const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : []; + const fieldNames = uniqueSorted([...availableFields, ...selectedFields]); + const fieldsWrap = createElement('div', 'deck-field-fields'); + 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); + fieldsWrap.append(fieldActions, checkboxList); + + 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('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(); + }); + + row.append(deckSelect, fieldsWrap, removeButton); + 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; +} diff --git a/src/settings/settings-control-context.ts b/src/settings/settings-control-context.ts new file mode 100644 index 00000000..99f696d2 --- /dev/null +++ b/src/settings/settings-control-context.ts @@ -0,0 +1,8 @@ +import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings'; + +export interface SettingsControlContext { + setFieldError(path: string, message: string | null): void; + updateDraft(path: string, value: ConfigSettingsSnapshotValue): void; + valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue; + valueForPath(path: string): ConfigSettingsSnapshotValue | undefined; +} diff --git a/src/settings/settings-control-dom.ts b/src/settings/settings-control-dom.ts new file mode 100644 index 00000000..e62c25be --- /dev/null +++ b/src/settings/settings-control-dom.ts @@ -0,0 +1,34 @@ +import type { ConfigSettingsSnapshotValue } from '../types/settings'; + +export function createElement( + 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[] { + 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', + ); +} diff --git a/src/settings/settings-controls.ts b/src/settings/settings-controls.ts new file mode 100644 index 00000000..331f4687 --- /dev/null +++ b/src/settings/settings-controls.ts @@ -0,0 +1,202 @@ +import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings'; +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 { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls'; + +export { renderNoteFieldModelPicker }; + +export function configureSettingsControls(options: { requestRender: () => void }): void { + configureAnkiControls(options); +} + +export function initializeSettingsControls( + values: Record, +): 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; +} + +export function renderControl( + field: ConfigSettingsField, + context: SettingsControlContext, +): HTMLElement { + const value = 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 === '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'; + input.value = typeof value === 'number' ? String(value) : ''; + input.addEventListener('input', () => { + const next = parseOptionalNumberInputValue(input.value); + if (next.ok) { + input.classList.remove('invalid'); + context.setFieldError(field.configPath, null); + context.updateDraft(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 === '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; +} diff --git a/src/settings/settings-keybinding-controls.ts b/src/settings/settings-keybinding-controls.ts new file mode 100644 index 00000000..9e0f3672 --- /dev/null +++ b/src/settings/settings-keybinding-controls.ts @@ -0,0 +1,138 @@ +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; + +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 (const row of rows) { + 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); + }); + item.append(keyButton, command); + container.append(item); + } + + return container; +} diff --git a/src/settings/settings-model.test.ts b/src/settings/settings-model.test.ts index 08784866..e91213be 100644 --- a/src/settings/settings-model.test.ts +++ b/src/settings/settings-model.test.ts @@ -14,8 +14,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 Pause Behavior', control: 'boolean', defaultValue: true, restartBehavior: 'hot-reload', @@ -35,12 +35,12 @@ const fields: ConfigSettingsField[] = [ 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' }), []); }); test('settings draft tracks dirty set and emits save operations', () => { diff --git a/src/settings/settings-model.ts b/src/settings/settings-model.ts index 23969cd9..29f4fada 100644 --- a/src/settings/settings-model.ts +++ b/src/settings/settings-model.ts @@ -41,6 +41,7 @@ export function filterSettingsFields( field.description, field.configPath, field.section, + field.subsection ?? '', field.enumValues?.join(' ') ?? '', ] .join(' ') diff --git a/src/settings/settings.ts b/src/settings/settings.ts index b30ae893..f586b16a 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -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, @@ -23,7 +28,8 @@ declare global { } const CATEGORY_LABELS: Record = { - viewing: 'Viewing', + appearance: 'Appearance', + behavior: 'Behavior', 'mining-anki': 'Mining & Anki', 'playback-sources': 'Playback & Sources', input: 'Input', @@ -33,7 +39,8 @@ const CATEGORY_LABELS: Record = { }; const CATEGORY_ORDER: ConfigSettingsCategory[] = [ - 'viewing', + 'appearance', + 'behavior', 'mining-anki', 'playback-sources', 'input', @@ -51,7 +58,7 @@ const state: { } = { snapshot: null, draft: null, - category: 'viewing', + category: 'appearance', query: '', inputErrors: new Map(), }; @@ -76,12 +83,6 @@ const dom = { settingsContent: getElement('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}`; @@ -132,7 +133,19 @@ function createFieldMeta(field: ConfigSettingsField): HTMLElement { } function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue { - return state.draft?.values[field.configPath] ?? field.defaultValue; + if (!state.draft) { + return field.defaultValue; + } + return Object.hasOwn(state.draft.values, field.configPath) + ? 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,128 +163,6 @@ 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 renderWarnings(snapshot: ConfigSettingsSnapshot): void { dom.warningsPanel.replaceChildren(); if (snapshot.warnings.length === 0) { @@ -330,7 +221,7 @@ function renderField(field: ConfigSettingsField): HTMLElement { header.append(label, description, createFieldMeta(field)); const controlWrap = createElement('div', 'field-control'); - controlWrap.append(renderControl(field)); + controlWrap.append(renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath })); const resetButton = createElement('button', 'reset-button') as HTMLButtonElement; resetButton.type = 'button'; resetButton.textContent = 'Reset'; @@ -374,7 +265,19 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void { const title = createElement('h2'); title.textContent = section; sectionEl.append(title); + if (section === 'Note Fields') { + sectionEl.append( + renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }), + ); + } + let currentSubsection = ''; for (const field of sectionFields) { + 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 +293,14 @@ function render(): void { syncSaveButton(); } +configureSettingsControls({ requestRender: render }); + async function loadSnapshot(): Promise { clearStatus(); const snapshot = await window.configSettingsAPI.getSnapshot(); state.snapshot = snapshot; state.draft = createSettingsDraft(snapshot.values); + initializeSettingsControls(snapshot.values); state.inputErrors.clear(); render(); } @@ -406,34 +312,36 @@ async function save(): Promise { 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', () => { @@ -444,7 +352,9 @@ dom.saveButton.addEventListener('click', () => { void save(); }); dom.openFileButton.addEventListener('click', () => { - void window.configSettingsAPI.openSettingsFile(); + void window.configSettingsAPI.openSettingsFile().catch((error) => { + setStatus(error instanceof Error ? error.message : 'Failed to open settings file', 'error'); + }); }); void loadSnapshot().catch((error) => { diff --git a/src/settings/style.css b/src/settings/style.css index 897d37b3..9acb347c 100644 --- a/src/settings/style.css +++ b/src/settings/style.css @@ -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; @@ -324,7 +331,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 +348,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 +374,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 +506,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,6 +531,14 @@ code { border-top: none; } +.settings-subsection-title + .field-row { + border-top: none; +} + +.helper-row { + background: rgba(138, 173, 244, 0.04); +} + .field-copy h3 { margin: 0 0 5px; font-size: 14px; @@ -555,6 +602,108 @@ 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: grid; + grid-template-columns: minmax(140px, 0.75fr) minmax(220px, 1.25fr) auto; + gap: 8px; + align-items: start; +} + +.deck-field-fields { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.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(220px, 1.22fr); + 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 +760,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 +820,9 @@ code { .settings-toolbar, .field-row, - .field-control { + .field-control, + .deck-field-row, + .keybinding-row { display: flex; flex-direction: column; align-items: stretch; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 111191a8..3d7c2fcf 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -101,6 +101,10 @@ export const IPC_CHANNELS = { saveConfigSettingsPatch: 'config:save-settings-patch', openConfigSettingsFile: 'config:open-settings-file', openConfigSettingsWindow: 'config:open-settings-window', + getConfigSettingsAnkiDeckNames: 'config-settings:anki-deck-names', + getConfigSettingsAnkiDeckFieldNames: 'config-settings:anki-deck-field-names', + getConfigSettingsAnkiModelNames: 'config-settings:anki-model-names', + getConfigSettingsAnkiModelFieldNames: 'config-settings:anki-model-field-names', }, event: { subtitleSet: 'subtitle:set', diff --git a/src/types/config.ts b/src/types/config.ts index cb443dc9..2e7a6852 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -212,10 +212,8 @@ export interface ResolvedConfig { addMinedWordsImmediately: boolean; matchMode: NPlusOneMatchMode; decks: Record; - color: string; }; nPlusOne: { - nPlusOne: string; minSentenceWords: number; }; behavior: { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 2f012312..3deb6005 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -31,6 +31,7 @@ import type { SubtitlePosition, SubtitleSidebarConfig, SubtitleSidebarSnapshot, + SubtitleRendererStyleConfig, SubtitleStyleConfig, } from './subtitle'; import type { @@ -343,7 +344,7 @@ export interface ConfigHotReloadPayload { keybindings: Keybinding[]; sessionBindings: CompiledSessionBinding[]; sessionBindingWarnings: SessionBindingWarning[]; - subtitleStyle: SubtitleStyleConfig | null; + subtitleStyle: SubtitleRendererStyleConfig | null; subtitleSidebar: Required; primarySubMode: PrimarySubMode; secondarySubMode: SecondarySubMode; @@ -426,7 +427,7 @@ export interface ElectronAPI { getSecondarySubMode: () => Promise; getCurrentSecondarySub: () => Promise; focusMainWindow: () => Promise; - getSubtitleStyle: () => Promise; + getSubtitleStyle: () => Promise; onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void; diff --git a/src/types/settings.ts b/src/types/settings.ts index c796fb4f..e1f6e51b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,7 +1,8 @@ import type { ConfigValidationWarning } from './config'; export type ConfigSettingsCategory = - | 'viewing' + | 'appearance' + | 'behavior' | 'mining-anki' | 'playback-sources' | 'input' @@ -18,7 +19,14 @@ export type ConfigSettingsControl = | 'color' | 'string-list' | 'json' - | 'secret'; + | 'secret' + | 'keyboard-shortcut' + | 'key-code' + | 'known-words-decks' + | 'anki-note-type' + | 'anki-field' + | 'mpv-keybindings' + | 'color-list'; export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart'; @@ -29,6 +37,7 @@ export interface ConfigSettingsField { configPath: string; category: ConfigSettingsCategory; section: string; + subsection?: string; control: ConfigSettingsControl; defaultValue: unknown; enumValues?: readonly string[]; @@ -77,4 +86,20 @@ export interface ConfigSettingsAPI { savePatch(patch: ConfigSettingsPatch): Promise; openSettingsFile(): Promise; openSettingsWindow(): Promise; + getAnkiDeckNames(draftUrl?: string): Promise; + getAnkiDeckFieldNames( + deckName: string, + draftUrl?: string, + ): Promise; + getAnkiModelNames(draftUrl?: string): Promise; + getAnkiModelFieldNames( + modelName: string, + draftUrl?: string, + ): Promise; +} + +export interface ConfigSettingsAnkiListResult { + ok: boolean; + values: string[]; + error?: string; } diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 0da3dec2..a145844a 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -126,6 +126,8 @@ export interface SubtitleStyleConfig { }; } +export type SubtitleRendererStyleConfig = SubtitleStyleConfig; + export interface TokenPos1ExclusionConfig { defaults?: string[]; add?: string[];