mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): reorganize settings window and move annotation colors to subtitleStyle
- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections - Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section - Add click-to-learn keybinding controls - Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings - Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient - Mark discordPresence.presenceStyle as an enum in the config registry
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -381,8 +381,8 @@
|
|||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -512,8 +512,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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"] }.
|
"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.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"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
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"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.
|
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
@@ -648,7 +646,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -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:
|
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
|
- Mining & Anki
|
||||||
- Playback & Sources
|
- Playback & Sources
|
||||||
- Input
|
- Input
|
||||||
@@ -75,13 +76,13 @@ SubMiner also includes a dedicated **Configuration** window from the tray menu,
|
|||||||
- Tracking & App
|
- Tracking & App
|
||||||
- Advanced
|
- 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.
|
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.
|
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.
|
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 |
|
| `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) |
|
| `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`) |
|
| `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.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.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) |
|
| `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.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `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 |
|
| `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`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
| `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`) |
|
| `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.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.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.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.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.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`). |
|
| `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.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `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.
|
- 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.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.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.
|
- `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.
|
- 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).
|
- 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 |
|
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `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=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
|
|||||||
@@ -381,8 +381,8 @@
|
|||||||
"fontStyle": "normal", // Font style setting.
|
"fontStyle": "normal", // Font style setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"backgroundColor": "transparent", // Background color setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -512,8 +512,7 @@
|
|||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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"] }.
|
"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.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
"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
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"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.
|
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
@@ -648,7 +646,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"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.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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`).
|
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 `knownWordColor` (default: `#a6da95`).
|
5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`).
|
||||||
|
|
||||||
**Key settings:**
|
**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.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.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.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||||
| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
|
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||||
|
|||||||
@@ -48,3 +48,78 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
|
|||||||
console.info = originalInfo;
|
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<unknown> };
|
||||||
|
};
|
||||||
|
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<unknown> };
|
||||||
|
};
|
||||||
|
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] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -156,6 +156,48 @@ export class AnkiConnectClient {
|
|||||||
return (result as number[]) || [];
|
return (result as number[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deckNames(): Promise<string[]> {
|
||||||
|
const result = await this.invoke('deckNames');
|
||||||
|
return Array.isArray(result)
|
||||||
|
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelNames(): Promise<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string>();
|
||||||
|
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<Record<string, unknown>[]> {
|
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||||
const result = await this.invoke('notesInfo', { notes: noteIds });
|
const result = await this.invoke('notesInfo', { notes: noteIds });
|
||||||
return (result as Record<string, unknown>[]) || [];
|
return (result as Record<string, unknown>[]) || [];
|
||||||
|
|||||||
@@ -1846,7 +1846,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
|||||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
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();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
path.join(dir, 'config.jsonc'),
|
||||||
@@ -1867,13 +1867,13 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
|||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
const warnings = service.getWarnings();
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
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.nPlusOne.nPlusOne'));
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
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();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, 'config.jsonc'),
|
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 service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
|
|
||||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
|
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
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'],
|
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||||
'Kaishi 1.5k': ['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(
|
assert.ok(
|
||||||
warnings.some(
|
warnings.some(
|
||||||
(warning) =>
|
(warning) =>
|
||||||
@@ -2280,9 +2280,9 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"characterDictionary":\s*\{/);
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
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"\s*:\s*\{/);
|
||||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
|
||||||
assert.match(output, /"minSentenceWords": 3/);
|
assert.match(output, /"minSentenceWords": 3/);
|
||||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
addMinedWordsImmediately: true,
|
addMinedWordsImmediately: true,
|
||||||
matchMode: 'headword',
|
matchMode: 'headword',
|
||||||
decks: {},
|
decks: {},
|
||||||
color: '#a6da95',
|
|
||||||
},
|
},
|
||||||
behavior: {
|
behavior: {
|
||||||
overwriteAudio: true,
|
overwriteAudio: true,
|
||||||
@@ -71,7 +70,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
minSentenceWords: 3,
|
minSentenceWords: 3,
|
||||||
nPlusOne: '#c6a0f6',
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
pattern: '[SubMiner] %f (%t)',
|
pattern: '[SubMiner] %f (%t)',
|
||||||
|
|||||||
@@ -63,10 +63,8 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
|||||||
'subtitleStyle.jlptColors.N3',
|
'subtitleStyle.jlptColors.N3',
|
||||||
'subtitleStyle.jlptColors.N4',
|
'subtitleStyle.jlptColors.N4',
|
||||||
'subtitleStyle.jlptColors.N5',
|
'subtitleStyle.jlptColors.N5',
|
||||||
'subtitleStyle.knownWordColor',
|
|
||||||
'subtitleStyle.letterSpacing',
|
'subtitleStyle.letterSpacing',
|
||||||
'subtitleStyle.lineHeight',
|
'subtitleStyle.lineHeight',
|
||||||
'subtitleStyle.nPlusOneColor',
|
|
||||||
'subtitleStyle.secondary.backdropFilter',
|
'subtitleStyle.secondary.backdropFilter',
|
||||||
'subtitleStyle.secondary.backgroundColor',
|
'subtitleStyle.secondary.backgroundColor',
|
||||||
'subtitleStyle.secondary.fontColor',
|
'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);
|
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', () => {
|
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 registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||||
|
|||||||
@@ -291,18 +291,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
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"] }.',
|
'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',
|
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
@@ -567,7 +555,8 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'discordPresence.presenceStyle',
|
path: 'discordPresence.presenceStyle',
|
||||||
kind: 'string',
|
kind: 'enum',
|
||||||
|
enumValues: ['default', 'meme', 'japanese', 'minimal'],
|
||||||
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
defaultValue: defaultConfig.discordPresence.presenceStyle,
|
||||||
description:
|
description:
|
||||||
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
'Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".',
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
'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',
|
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -946,47 +946,68 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
|
||||||
|
? (context.src.subtitleStyle as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const hasCanonicalNPlusOneColor = rawSubtitleStyle.nPlusOneColor !== undefined;
|
||||||
|
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
|
||||||
|
|
||||||
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
|
||||||
if (nPlusOneHighlightColor !== undefined) {
|
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) {
|
} else if (nPlusOneConfig.nPlusOne !== undefined) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.nPlusOne.nPlusOne',
|
'ankiConnect.nPlusOne.nPlusOne',
|
||||||
nPlusOneConfig.nPlusOne,
|
nPlusOneConfig.nPlusOne,
|
||||||
context.resolved.ankiConnect.nPlusOne.nPlusOne,
|
context.resolved.subtitleStyle.nPlusOneColor,
|
||||||
'Expected a hex color value.',
|
'Expected a hex color value.',
|
||||||
);
|
);
|
||||||
context.resolved.ankiConnect.nPlusOne.nPlusOne = DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownWordsColor = asColor(knownWordsConfig.color);
|
const knownWordsColor = asColor(knownWordsConfig.color);
|
||||||
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
|
||||||
if (knownWordsColor !== undefined) {
|
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) {
|
} else if (knownWordsConfig.color !== undefined) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.knownWords.color',
|
'ankiConnect.knownWords.color',
|
||||||
knownWordsConfig.color,
|
knownWordsConfig.color,
|
||||||
context.resolved.ankiConnect.knownWords.color,
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
'Expected a hex color value.',
|
'Expected a hex color value.',
|
||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
|
||||||
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
} else if (legacyNPlusOneKnownWordColor !== undefined) {
|
||||||
context.resolved.ankiConnect.knownWords.color = legacyNPlusOneKnownWordColor;
|
if (!hasCanonicalKnownWordColor) {
|
||||||
|
context.resolved.subtitleStyle.knownWordColor = legacyNPlusOneKnownWordColor;
|
||||||
|
}
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
'ankiConnect.nPlusOne.knownWord',
|
||||||
nPlusOneConfig.knownWord,
|
nPlusOneConfig.knownWord,
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.color,
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
'Legacy key is deprecated; use ankiConnect.knownWords.color',
|
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
|
||||||
);
|
);
|
||||||
} else if (nPlusOneConfig.knownWord !== undefined) {
|
} else if (nPlusOneConfig.knownWord !== undefined) {
|
||||||
context.warn(
|
context.warn(
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
'ankiConnect.nPlusOne.knownWord',
|
||||||
nPlusOneConfig.knownWord,
|
nPlusOneConfig.knownWord,
|
||||||
context.resolved.ankiConnect.knownWords.color,
|
context.resolved.subtitleStyle.knownWordColor,
|
||||||
'Expected a hex color value.',
|
'Expected a hex color value.',
|
||||||
);
|
);
|
||||||
context.resolved.ankiConnect.knownWords.color = DEFAULT_CONFIG.ankiConnect.knownWords.color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||||
|
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||||
|
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||||
const fallbackFrequencyDictionary = {
|
const fallbackFrequencyDictionary = {
|
||||||
...resolved.subtitleStyle.frequencyDictionary,
|
...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(
|
const frequencyDictionary = isObject(
|
||||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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', () => {
|
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||||
const valid = createResolveContext({
|
const valid = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
@@ -1,39 +1,75 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { DEFAULT_CONFIG } from '../definitions';
|
import { DEFAULT_CONFIG } from '../definitions';
|
||||||
import {
|
import { buildConfigSettingsRegistry } from './registry';
|
||||||
buildConfigSettingsRegistry,
|
|
||||||
getConfigSettingsCoverage,
|
|
||||||
LEGACY_HIDDEN_CONFIG_PATHS,
|
|
||||||
} from './registry';
|
|
||||||
|
|
||||||
test('config settings registry places hover pause under viewing playback behavior', () => {
|
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
|
||||||
const hoverPause = fields.find(
|
|
||||||
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(hoverPause);
|
function field(path: string) {
|
||||||
assert.equal(hoverPause.category, 'viewing');
|
const match = fields.find((candidate) => candidate.configPath === path);
|
||||||
assert.equal(hoverPause.section, 'Playback pause behavior');
|
assert.ok(match, `missing settings field: ${path}`);
|
||||||
assert.equal(hoverPause.control, 'boolean');
|
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', () => {
|
test('settings registry groups annotation display fields by config group', () => {
|
||||||
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||||
const visiblePaths = new Set(
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||||
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
|
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) {
|
const kikuLapis = fields.filter(
|
||||||
assert.equal(visiblePaths.has(path), false, path);
|
(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);
|
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
|
||||||
});
|
|
||||||
|
|
||||||
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, []);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+198
-23
@@ -46,20 +46,30 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'ankiConnect.nPlusOne.matchMode',
|
'ankiConnect.nPlusOne.matchMode',
|
||||||
'ankiConnect.nPlusOne.decks',
|
'ankiConnect.nPlusOne.decks',
|
||||||
'ankiConnect.nPlusOne.knownWord',
|
'ankiConnect.nPlusOne.knownWord',
|
||||||
|
'ankiConnect.nPlusOne.nPlusOne',
|
||||||
|
'ankiConnect.knownWords.color',
|
||||||
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
'ankiConnect.behavior.nPlusOneHighlightEnabled',
|
||||||
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
'ankiConnect.behavior.nPlusOneRefreshMinutes',
|
||||||
'ankiConnect.behavior.nPlusOneMatchMode',
|
'ankiConnect.behavior.nPlusOneMatchMode',
|
||||||
'ankiConnect.isLapis.sentenceCardSentenceField',
|
'ankiConnect.isLapis.sentenceCardSentenceField',
|
||||||
'ankiConnect.isLapis.sentenceCardAudioField',
|
'ankiConnect.isLapis.sentenceCardAudioField',
|
||||||
|
'controller.bindings',
|
||||||
|
'controller.preferredGamepadId',
|
||||||
|
'controller.preferredGamepadLabel',
|
||||||
|
'controller.profiles',
|
||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
'jellyfin.accessToken',
|
'jellyfin.accessToken',
|
||||||
'jellyfin.userId',
|
'jellyfin.userId',
|
||||||
|
'jellyfin.clientName',
|
||||||
|
'jellyfin.clientVersion',
|
||||||
|
'jellyfin.defaultLibraryId',
|
||||||
|
'jellyfin.deviceId',
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
|
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
|
||||||
|
|
||||||
const JSON_OBJECT_FIELDS = new Set([
|
const JSON_OBJECT_FIELDS = new Set([
|
||||||
'keybindings',
|
'keybindings',
|
||||||
@@ -75,12 +85,79 @@ const COLOR_SUFFIXES = new Set([
|
|||||||
'color',
|
'color',
|
||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
'singleColor',
|
'singleColor',
|
||||||
'knownWordColor',
|
|
||||||
'nPlusOne',
|
'nPlusOne',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
|
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<string, number>(
|
||||||
|
[
|
||||||
|
'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<string, number>(
|
||||||
|
[
|
||||||
|
'ankiConnect.enabled',
|
||||||
|
'ankiConnect.proxy.enabled',
|
||||||
|
'ankiConnect.isLapis.enabled',
|
||||||
|
'ankiConnect.isKiku.enabled',
|
||||||
|
].map((path, index) => [path, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const SUBSECTION_ORDER = new Map<string, number>(
|
||||||
|
['Known Words', 'N+1', 'JLPT', 'Frequency Dictionary', 'Character Names'].map(
|
||||||
|
(subsection, index) => [subsection, index],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
'ankiConnect.pollingRate':
|
||||||
|
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
|
||||||
|
'ankiConnect.isKiku.enabled':
|
||||||
|
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
|
||||||
|
'ankiConnect.isLapis.enabled':
|
||||||
|
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||||
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -119,6 +196,10 @@ function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function humanizePath(path: string): string {
|
function humanizePath(path: string): string {
|
||||||
|
const override = LABEL_OVERRIDES[path];
|
||||||
|
if (override) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
const key = path.split('.').at(-1) ?? path;
|
const key = path.split('.').at(-1) ?? path;
|
||||||
const spaced = key
|
const spaced = key
|
||||||
.replace(/_/g, ' ')
|
.replace(/_/g, ' ')
|
||||||
@@ -138,7 +219,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||||
) {
|
) {
|
||||||
return { category: 'viewing', section: 'Playback pause behavior' };
|
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('ankiConnect.knownWords.') ||
|
path.startsWith('ankiConnect.knownWords.') ||
|
||||||
@@ -146,37 +227,51 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||||
path === 'subtitleStyle.enableJlpt' ||
|
path === 'subtitleStyle.enableJlpt' ||
|
||||||
|
path === 'subtitleStyle.knownWordColor' ||
|
||||||
|
path === 'subtitleStyle.nPlusOneColor' ||
|
||||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
path === 'subtitleStyle.nameMatchColor'
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
) {
|
) {
|
||||||
return { category: 'viewing', section: 'Annotation display' };
|
return { category: 'appearance', section: 'Annotation Display' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
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.')) {
|
if (path.startsWith('subtitleStyle.')) {
|
||||||
return { category: 'viewing', section: 'Primary subtitle appearance' };
|
return { category: 'appearance', section: 'Primary Subtitle Appearance' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleSidebar.')) {
|
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.')) {
|
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
|
||||||
return { category: 'viewing', section: 'Subtitle behavior' };
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.fields.')) {
|
if (path.startsWith('ankiConnect.fields.')) {
|
||||||
return { category: 'mining-anki', section: 'Note fields' };
|
return { category: 'mining-anki', section: 'Note Fields' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.media.')) {
|
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.')) {
|
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.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
return { category: 'mining-anki', section: 'Anki AI' };
|
return { category: 'mining-anki', section: 'Anki AI' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.proxy.')) {
|
if (path.startsWith('ankiConnect.proxy.')) {
|
||||||
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
|
return { category: 'mining-anki', section: 'AnkiConnect Proxy' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.')) {
|
if (path.startsWith('ankiConnect.')) {
|
||||||
return { category: 'mining-anki', section: '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) };
|
return { category: 'playback-sources', section: topSection(path) };
|
||||||
}
|
}
|
||||||
if (path.startsWith('shortcuts.')) {
|
if (path.startsWith('shortcuts.')) {
|
||||||
return { category: 'input', section: 'Overlay shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
if (path === 'keybindings') {
|
if (path === 'keybindings') {
|
||||||
return { category: 'input', section: 'MPV keybindings' };
|
return { category: 'input', section: 'MPV Keybindings' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('controller.')) {
|
if (path.startsWith('controller.')) {
|
||||||
return { category: 'input', section: 'Controller' };
|
return { category: 'input', section: 'Controller' };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('ai.') ||
|
path.startsWith('ai.') ||
|
||||||
path.startsWith('anilist.') ||
|
|
||||||
path.startsWith('yomitan.') ||
|
path.startsWith('yomitan.') ||
|
||||||
path.startsWith('jellyfin.') ||
|
path.startsWith('jellyfin.') ||
|
||||||
path.startsWith('discordPresence.') ||
|
path.startsWith('discordPresence.') ||
|
||||||
@@ -211,6 +305,12 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
) {
|
) {
|
||||||
return { category: 'integrations', section: topSection(path) };
|
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 (
|
if (
|
||||||
path.startsWith('immersionTracking.') ||
|
path.startsWith('immersionTracking.') ||
|
||||||
path.startsWith('stats.') ||
|
path.startsWith('stats.') ||
|
||||||
@@ -252,6 +352,21 @@ function topSection(path: string): string {
|
|||||||
|
|
||||||
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
||||||
if (SECRET_PATHS.has(path)) return 'secret';
|
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 (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
||||||
if (Array.isArray(value)) return 'string-list';
|
if (Array.isArray(value)) return 'string-list';
|
||||||
@@ -266,6 +381,70 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
|||||||
return 'json';
|
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 {
|
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||||
if (
|
if (
|
||||||
path === 'keybindings' ||
|
path === 'keybindings' ||
|
||||||
@@ -283,13 +462,15 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
||||||
const option = OPTION_BY_PATH.get(leaf.path);
|
const option = OPTION_BY_PATH.get(leaf.path);
|
||||||
const { category, section } = categoryAndSection(leaf.path);
|
const { category, section } = categoryAndSection(leaf.path);
|
||||||
|
const description = DESCRIPTION_OVERRIDES[leaf.path] ?? option?.description;
|
||||||
return {
|
return {
|
||||||
id: leaf.path,
|
id: leaf.path,
|
||||||
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(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,
|
configPath: leaf.path,
|
||||||
category,
|
category,
|
||||||
section,
|
section,
|
||||||
|
...(subsectionForPath(leaf.path) ? { subsection: subsectionForPath(leaf.path) } : {}),
|
||||||
control: controlForPath(leaf.path, leaf.value),
|
control: controlForPath(leaf.path, leaf.value),
|
||||||
defaultValue: leaf.value,
|
defaultValue: leaf.value,
|
||||||
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
|
||||||
@@ -306,13 +487,7 @@ export function buildConfigSettingsRegistry(
|
|||||||
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
|
||||||
): ConfigSettingsField[] {
|
): ConfigSettingsField[] {
|
||||||
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
|
||||||
return leaves.map(fieldForLeaf).sort((a, b) => {
|
return leaves.map(fieldForLeaf).sort(compareFields);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConfigSettingsCoverage(
|
export function getConfigSettingsCoverage(
|
||||||
|
|||||||
+5
-3
@@ -140,6 +140,7 @@ import {
|
|||||||
} from './cli/args';
|
} from './cli/args';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||||
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
import {
|
import {
|
||||||
getStartupModeFlags,
|
getStartupModeFlags,
|
||||||
shouldRefreshAnilistOnConfigReload,
|
shouldRefreshAnilistOnConfigReload,
|
||||||
@@ -682,8 +683,8 @@ const texthookerService = new Texthooker(() => {
|
|||||||
config.subtitleStyle.enableJlpt,
|
config.subtitleStyle.enableJlpt,
|
||||||
),
|
),
|
||||||
characterDictionaryEnabled,
|
characterDictionaryEnabled,
|
||||||
knownWordColor: config.ankiConnect.knownWords.color,
|
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||||
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
hoverTokenColor: config.subtitleStyle.hoverTokenColor,
|
||||||
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
hoverTokenBackgroundColor: config.subtitleStyle.hoverTokenBackgroundColor,
|
||||||
@@ -1813,7 +1814,8 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
getConfig: () => configService.getConfig(),
|
getConfig: () => configService.getConfig(),
|
||||||
getWarnings: () => configService.getWarnings(),
|
getWarnings: () => configService.getWarnings(),
|
||||||
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
reloadConfigStrict: () => configService.reloadConfigStrict(),
|
||||||
applyHotReload: (diff, config) => applyConfigHotReloadDiff(diff, config),
|
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||||
|
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||||
getSettingsWindow: () => appState.configSettingsWindow,
|
getSettingsWindow: () => appState.configSettingsWindow,
|
||||||
setSettingsWindow: (window) => {
|
setSettingsWindow: (window) => {
|
||||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...config.subtitleStyle,
|
...config.subtitleStyle,
|
||||||
nPlusOneColor: config.ankiConnect.nPlusOne.nPlusOne,
|
nPlusOneColor: config.subtitleStyle.nPlusOneColor,
|
||||||
knownWordColor: config.ankiConnect.knownWords.color,
|
knownWordColor: config.subtitleStyle.knownWordColor,
|
||||||
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
nameMatchColor: config.subtitleStyle.nameMatchColor,
|
||||||
enableJlpt: config.subtitleStyle.enableJlpt,
|
enableJlpt: config.subtitleStyle.enableJlpt,
|
||||||
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
frequencyDictionary: config.subtitleStyle.frequencyDictionary,
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import path from 'node:path';
|
|||||||
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
|
||||||
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
|
||||||
import type {
|
import type {
|
||||||
|
ConfigSettingsAnkiListResult,
|
||||||
ConfigSettingsField,
|
ConfigSettingsField,
|
||||||
ConfigSettingsSaveResult,
|
ConfigSettingsSaveResult,
|
||||||
ConfigSettingsSnapshot,
|
ConfigSettingsSnapshot,
|
||||||
} from '../../types/settings';
|
} from '../../types/settings';
|
||||||
import type { ReloadConfigStrictResult } from '../../config';
|
import type { ReloadConfigStrictResult } from '../../config';
|
||||||
import {
|
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||||
classifyConfigHotReloadDiff,
|
|
||||||
type ConfigHotReloadDiff,
|
|
||||||
} from '../../core/services/config-hot-reload';
|
|
||||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||||
import {
|
import {
|
||||||
createOpenConfigSettingsWindowHandler,
|
createOpenConfigSettingsWindowHandler,
|
||||||
@@ -28,6 +26,17 @@ export interface ConfigSettingsIpcChannels {
|
|||||||
saveConfigSettingsPatch: string;
|
saveConfigSettingsPatch: string;
|
||||||
openConfigSettingsFile: string;
|
openConfigSettingsFile: string;
|
||||||
openConfigSettingsWindow: string;
|
openConfigSettingsWindow: string;
|
||||||
|
getConfigSettingsAnkiDeckNames: string;
|
||||||
|
getConfigSettingsAnkiDeckFieldNames: string;
|
||||||
|
getConfigSettingsAnkiModelNames: string;
|
||||||
|
getConfigSettingsAnkiModelFieldNames: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSettingsAnkiClient {
|
||||||
|
deckNames(): Promise<string[]>;
|
||||||
|
fieldNamesForDeck(deckName: string): Promise<string[]>;
|
||||||
|
modelNames(): Promise<string[]>;
|
||||||
|
modelFieldNames(modelName: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
|
||||||
@@ -37,12 +46,13 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
|||||||
getConfig(): ResolvedConfig;
|
getConfig(): ResolvedConfig;
|
||||||
getWarnings(): ConfigValidationWarning[];
|
getWarnings(): ConfigValidationWarning[];
|
||||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||||
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
|
|
||||||
getSettingsWindow(): TWindow | null;
|
getSettingsWindow(): TWindow | null;
|
||||||
setSettingsWindow(window: TWindow | null): void;
|
setSettingsWindow(window: TWindow | null): void;
|
||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
openPath(path: string): Promise<string>;
|
openPath(path: string): Promise<string>;
|
||||||
|
defaultAnkiConnectUrl: string;
|
||||||
|
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||||
ipcMain: ConfigSettingsIpcMainLike;
|
ipcMain: ConfigSettingsIpcMainLike;
|
||||||
ipcChannels: ConfigSettingsIpcChannels;
|
ipcChannels: ConfigSettingsIpcChannels;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
@@ -111,7 +121,6 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
|
||||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||||
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
|
|
||||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +151,36 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAnkiConnectUrl(draftUrl: unknown): string {
|
||||||
|
return typeof draftUrl === 'string' && draftUrl.trim().length > 0
|
||||||
|
? draftUrl.trim()
|
||||||
|
: deps.getConfig().ankiConnect.url || deps.defaultAnkiConnectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAnkiList(
|
||||||
|
draftUrl: unknown,
|
||||||
|
lookup: (client: ConfigSettingsAnkiClient) => Promise<string[]>,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult> {
|
||||||
|
try {
|
||||||
|
const client = deps.createAnkiClient(getAnkiConnectUrl(draftUrl));
|
||||||
|
return { ok: true, values: await lookup(client) };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
values: [],
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to query AnkiConnect.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidAnkiListResult(error: string): ConfigSettingsAnkiListResult {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
values: [],
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function registerHandlers(): void {
|
function registerHandlers(): void {
|
||||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
|
||||||
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
|
||||||
@@ -155,6 +194,26 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
return openError.length === 0;
|
return openError.length === 0;
|
||||||
});
|
});
|
||||||
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
|
||||||
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiDeckNames, (_event, draftUrl) =>
|
||||||
|
getAnkiList(draftUrl, (client) => client.deckNames()),
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(
|
||||||
|
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
||||||
|
(_event, deckName, draftUrl) =>
|
||||||
|
typeof deckName === 'string'
|
||||||
|
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(deckName))
|
||||||
|
: invalidAnkiListResult('Deck name is required.'),
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
|
||||||
|
getAnkiList(draftUrl, (client) => client.modelNames()),
|
||||||
|
);
|
||||||
|
deps.ipcMain.handle(
|
||||||
|
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
|
||||||
|
(_event, modelName, draftUrl) =>
|
||||||
|
typeof modelName === 'string'
|
||||||
|
? getAnkiList(draftUrl, (client) => client.modelFieldNames(modelName))
|
||||||
|
: invalidAnkiListResult('Note type is required.'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function snapshot(): ConfigSettingsSnapshot {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('config settings save applies hot-reloadable diff live', () => {
|
test('config settings save returns hot-reloadable diff for watcher path', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const previous = DEFAULT_CONFIG;
|
const previous = DEFAULT_CONFIG;
|
||||||
const next: ResolvedConfig = {
|
const next: ResolvedConfig = {
|
||||||
@@ -46,7 +46,6 @@ test('config settings save applies hot-reloadable diff live', () => {
|
|||||||
hotReloadFields: ['subtitleStyle'],
|
hotReloadFields: ['subtitleStyle'],
|
||||||
restartRequiredFields: [],
|
restartRequiredFields: [],
|
||||||
}),
|
}),
|
||||||
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
|
|
||||||
getRestartRequiredSections: () => [],
|
getRestartRequiredSections: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +61,7 @@ test('config settings save applies hot-reloadable diff live', () => {
|
|||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.match(written, /autoPauseVideoOnHover/);
|
assert.match(written, /autoPauseVideoOnHover/);
|
||||||
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
|
assert.deepEqual(calls, ['write']);
|
||||||
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
|
||||||
assert.deepEqual(result.restartRequiredFields, []);
|
assert.deepEqual(result.restartRequiredFields, []);
|
||||||
});
|
});
|
||||||
@@ -95,7 +94,6 @@ test('config settings save returns restart-required sections without applying ho
|
|||||||
hotReloadFields: [],
|
hotReloadFields: [],
|
||||||
restartRequiredFields: ['mpv'],
|
restartRequiredFields: ['mpv'],
|
||||||
}),
|
}),
|
||||||
applyHotReload: () => calls.push('hot'),
|
|
||||||
getRestartRequiredSections: () => ['mpv launcher'],
|
getRestartRequiredSections: () => ['mpv launcher'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,9 +128,6 @@ test('config settings save restores previous file content when strict reload fai
|
|||||||
classifyDiff: () => {
|
classifyDiff: () => {
|
||||||
throw new Error('Should not classify invalid config.');
|
throw new Error('Should not classify invalid config.');
|
||||||
},
|
},
|
||||||
applyHotReload: () => {
|
|
||||||
throw new Error('Should not hot reload invalid config.');
|
|
||||||
},
|
|
||||||
getRestartRequiredSections: () => [],
|
getRestartRequiredSections: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface ConfigSettingsSaveDeps {
|
|||||||
deleteFile?(path: string): void;
|
deleteFile?(path: string): void;
|
||||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||||
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
|
|
||||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +63,17 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
|||||||
deps.writeTextAtomically(configPath, candidate.content);
|
deps.writeTextAtomically(configPath, candidate.content);
|
||||||
const reloadResult = deps.reloadConfigStrict();
|
const reloadResult = deps.reloadConfigStrict();
|
||||||
if (!reloadResult.ok) {
|
if (!reloadResult.ok) {
|
||||||
if (hadExistingConfig) {
|
try {
|
||||||
deps.writeTextAtomically(configPath, content);
|
if (hadExistingConfig) {
|
||||||
} else if (deps.deleteFile) {
|
deps.writeTextAtomically(configPath, content);
|
||||||
deps.deleteFile(configPath);
|
} else if (deps.deleteFile) {
|
||||||
} else {
|
deps.deleteFile(configPath);
|
||||||
deps.writeTextAtomically(configPath, content);
|
} else {
|
||||||
|
deps.writeTextAtomically(configPath, content);
|
||||||
|
}
|
||||||
|
deps.reloadConfigStrict();
|
||||||
|
} catch {
|
||||||
|
// Best-effort rollback; preserve original reload error for caller.
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -82,9 +86,6 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||||
if (diff.hotReloadFields.length > 0) {
|
|
||||||
deps.applyHotReload(diff, reloadResult.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: false,
|
|
||||||
preload: '/tmp/preload-settings.js',
|
preload: '/tmp/preload-settings.js',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
|||||||
title: 'SubMiner Configuration',
|
title: 'SubMiner Configuration',
|
||||||
resizable: true,
|
resizable: true,
|
||||||
preloadPath: deps.preloadPath,
|
preloadPath: deps.preloadPath,
|
||||||
sandbox: false,
|
|
||||||
backgroundColor: '#24273a',
|
backgroundColor: '#24273a',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))?['"]/);
|
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}:`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
|
ConfigSettingsAnkiListResult,
|
||||||
ConfigSettingsAPI,
|
ConfigSettingsAPI,
|
||||||
ConfigSettingsPatch,
|
ConfigSettingsPatch,
|
||||||
ConfigSettingsSaveResult,
|
ConfigSettingsSaveResult,
|
||||||
@@ -11,6 +12,10 @@ const SETTINGS_IPC_CHANNELS = {
|
|||||||
savePatch: 'config:save-settings-patch',
|
savePatch: 'config:save-settings-patch',
|
||||||
openFile: 'config:open-settings-file',
|
openFile: 'config:open-settings-file',
|
||||||
openWindow: 'config:open-settings-window',
|
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;
|
} as const;
|
||||||
|
|
||||||
const configSettingsAPI: ConfigSettingsAPI = {
|
const configSettingsAPI: ConfigSettingsAPI = {
|
||||||
@@ -20,6 +25,20 @@ const configSettingsAPI: ConfigSettingsAPI = {
|
|||||||
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.savePatch, patch),
|
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.savePatch, patch),
|
||||||
openSettingsFile: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openFile),
|
openSettingsFile: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openFile),
|
||||||
openSettingsWindow: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openWindow),
|
openSettingsWindow: (): Promise<boolean> => ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.openWindow),
|
||||||
|
getAnkiDeckNames: (draftUrl?: string): Promise<ConfigSettingsAnkiListResult> =>
|
||||||
|
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckNames, draftUrl),
|
||||||
|
getAnkiDeckFieldNames: (
|
||||||
|
deckName: string,
|
||||||
|
draftUrl?: string,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult> =>
|
||||||
|
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiDeckFieldNames, deckName, draftUrl),
|
||||||
|
getAnkiModelNames: (draftUrl?: string): Promise<ConfigSettingsAnkiListResult> =>
|
||||||
|
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelNames, draftUrl),
|
||||||
|
getAnkiModelFieldNames: (
|
||||||
|
modelName: string,
|
||||||
|
draftUrl?: string,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult> =>
|
||||||
|
ipcRenderer.invoke(SETTINGS_IPC_CHANNELS.getAnkiModelFieldNames, modelName, draftUrl),
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
|
contextBridge.exposeInMainWorld('configSettingsAPI', configSettingsAPI);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
PrimarySubMode,
|
PrimarySubMode,
|
||||||
SecondarySubMode,
|
SecondarySubMode,
|
||||||
SubtitleData,
|
SubtitleData,
|
||||||
SubtitleStyleConfig,
|
SubtitleRendererStyleConfig,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { RendererContext } from './context';
|
import type { RendererContext } from './context';
|
||||||
|
|
||||||
@@ -635,7 +635,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`);
|
document.documentElement.style.setProperty('--subtitle-font-size', `${clampedSize}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySubtitleStyle(style: SubtitleStyleConfig | null): void {
|
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
|
||||||
if (!style) return;
|
if (!style) return;
|
||||||
|
|
||||||
const styleDeclarations = style as Record<string, unknown>;
|
const styleDeclarations = style as Record<string, unknown>;
|
||||||
|
|||||||
@@ -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, [])),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
Backquote: 'Backquote',
|
||||||
|
Backslash: 'Backslash',
|
||||||
|
BracketLeft: 'BracketLeft',
|
||||||
|
BracketRight: 'BracketRight',
|
||||||
|
Comma: 'Comma',
|
||||||
|
Delete: 'Delete',
|
||||||
|
End: 'End',
|
||||||
|
Enter: 'Enter',
|
||||||
|
Equal: 'Plus',
|
||||||
|
Escape: 'Escape',
|
||||||
|
Home: 'Home',
|
||||||
|
Insert: 'Insert',
|
||||||
|
Minus: 'Minus',
|
||||||
|
PageDown: 'PageDown',
|
||||||
|
PageUp: 'PageUp',
|
||||||
|
Period: 'Period',
|
||||||
|
Quote: 'Quote',
|
||||||
|
Semicolon: 'Semicolon',
|
||||||
|
Slash: 'Slash',
|
||||||
|
Space: 'Space',
|
||||||
|
Tab: 'Tab',
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
const rows = defaultBindings.map((binding) => {
|
||||||
|
const override = userByKey.get(binding.key);
|
||||||
|
if (override?.command === null) {
|
||||||
|
const movedOverride = normalizedUserBindings.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.key !== binding.key && commandEquals(candidate.command, binding.command),
|
||||||
|
);
|
||||||
|
if (movedOverride) {
|
||||||
|
consumedUserKeys.add(binding.key);
|
||||||
|
consumedUserKeys.add(movedOverride.key);
|
||||||
|
return {
|
||||||
|
defaultKey: binding.key,
|
||||||
|
key: movedOverride.key,
|
||||||
|
command: movedOverride.command,
|
||||||
|
commandText: JSON.stringify(movedOverride.command ?? null),
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (override) {
|
||||||
|
consumedUserKeys.add(binding.key);
|
||||||
|
}
|
||||||
|
const command = override?.command ?? binding.command;
|
||||||
|
return {
|
||||||
|
defaultKey: binding.key,
|
||||||
|
key: binding.key,
|
||||||
|
command,
|
||||||
|
commandText: JSON.stringify(command ?? null),
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const binding of normalizedUserBindings) {
|
||||||
|
if (consumedUserKeys.has(binding.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (defaultBindings.some((defaultBinding) => defaultBinding.key === binding.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
defaultKey: binding.key,
|
||||||
|
key: binding.key,
|
||||||
|
command: binding.command,
|
||||||
|
commandText: JSON.stringify(binding.command ?? null),
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMpvCommandText(value: string): Keybinding['command'] | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed === null) return null;
|
||||||
|
if (
|
||||||
|
Array.isArray(parsed) &&
|
||||||
|
parsed.every((entry) => typeof entry === 'string' || typeof entry === 'number')
|
||||||
|
) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMpvKeybindingConfigValue(
|
||||||
|
defaultBindings: Keybinding[],
|
||||||
|
rows: MpvKeybindingRow[],
|
||||||
|
): Keybinding[] {
|
||||||
|
const next: Keybinding[] = [];
|
||||||
|
|
||||||
|
for (const defaultBinding of defaultBindings) {
|
||||||
|
const row = rows.find((candidate) => candidate.defaultKey === defaultBinding.key);
|
||||||
|
if (!row) {
|
||||||
|
next.push({ key: defaultBinding.key, command: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.key !== defaultBinding.key) {
|
||||||
|
next.push({ key: defaultBinding.key, command: null });
|
||||||
|
if (row.command !== null) {
|
||||||
|
next.push({ key: row.key, command: row.command });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandEquals(row.command, defaultBinding.command)) {
|
||||||
|
next.push({ key: row.key, command: row.command });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows.filter((candidate) => !candidate.isDefault)) {
|
||||||
|
next.push({ key: row.key, command: row.command });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<string, string[]>;
|
||||||
|
deckFieldNamesLoading: Set<string>;
|
||||||
|
deckFieldNamesErrors: Map<string, string>;
|
||||||
|
modelNames: string[] | null;
|
||||||
|
modelNamesLoading: boolean;
|
||||||
|
modelNamesError: string | null;
|
||||||
|
modelFieldNames: Map<string, string[]>;
|
||||||
|
modelFieldNamesLoading: Set<string>;
|
||||||
|
modelFieldNamesErrors: Map<string, string>;
|
||||||
|
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<string, ConfigSettingsSnapshotValue>): 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<string, string[]> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const decks: Record<string, string[]> = {};
|
||||||
|
for (const [deckName, fields] of Object.entries(value)) {
|
||||||
|
if (!deckName) continue;
|
||||||
|
decks[deckName] = normalizeStringArray(fields);
|
||||||
|
}
|
||||||
|
return decks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setKnownWordsDecks(
|
||||||
|
context: SettingsControlContext,
|
||||||
|
path: string,
|
||||||
|
decks: Record<string, string[]>,
|
||||||
|
): void {
|
||||||
|
const next: Record<string, string[]> = {};
|
||||||
|
for (const [deckName, fields] of Object.entries(decks)) {
|
||||||
|
if (!deckName) continue;
|
||||||
|
next[deckName] = uniqueSorted(fields);
|
||||||
|
}
|
||||||
|
context.updateDraft(path, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraftAnkiConnectUrl(context: SettingsControlContext): string | undefined {
|
||||||
|
const value = context.valueForPath('ankiConnect.url');
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<HTMLInputElement>('input[type="checkbox"]:checked'),
|
||||||
|
].map((input) => input.value);
|
||||||
|
setSelectedFields(checkedFields);
|
||||||
|
});
|
||||||
|
const text = createElement('span');
|
||||||
|
text.textContent = candidateField;
|
||||||
|
label.append(checkbox, text);
|
||||||
|
checkboxList.append(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||||
|
removeButton.type = 'button';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.addEventListener('click', () => {
|
||||||
|
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
|
||||||
|
delete nextDecks[deckName];
|
||||||
|
setKnownWordsDecks(context, field.configPath, nextDecks);
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||||
|
|
||||||
|
export function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tagName: K,
|
||||||
|
className?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const element = document.createElement(tagName);
|
||||||
|
if (className) {
|
||||||
|
element.className = className;
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addOption(select: HTMLSelectElement, value: string, label = value): void {
|
||||||
|
const option = createElement('option') as HTMLOptionElement;
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = label;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueSorted(values: Iterable<string>): string[] {
|
||||||
|
return [...new Set([...values].filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecretSnapshotValue(
|
||||||
|
value: ConfigSettingsSnapshotValue,
|
||||||
|
): value is { configured: boolean } {
|
||||||
|
return Boolean(
|
||||||
|
value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
'configured' in value &&
|
||||||
|
typeof (value as { configured?: unknown }).configured === 'boolean',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<string, ConfigSettingsSnapshotValue>,
|
||||||
|
): void {
|
||||||
|
initializeAnkiControls(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColorListInput(
|
||||||
|
context: SettingsControlContext,
|
||||||
|
field: ConfigSettingsField,
|
||||||
|
value: ConfigSettingsSnapshotValue,
|
||||||
|
): HTMLElement {
|
||||||
|
const colors = Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
const container = createElement('div', 'color-list');
|
||||||
|
for (let i = 0; i < colors.length; i++) {
|
||||||
|
const row = createElement('div', 'color-list-row');
|
||||||
|
const label = createElement('span', 'color-list-label');
|
||||||
|
label.textContent = `Band ${i + 1}`;
|
||||||
|
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||||
|
input.type = 'color';
|
||||||
|
input.value = colors[i] ?? '#000000';
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const updated = [...colors];
|
||||||
|
updated[i] = input.value;
|
||||||
|
context.updateDraft(field.configPath, updated);
|
||||||
|
});
|
||||||
|
row.append(label, input);
|
||||||
|
container.append(row);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJsonInput(
|
||||||
|
context: SettingsControlContext,
|
||||||
|
field: ConfigSettingsField,
|
||||||
|
value: ConfigSettingsSnapshotValue,
|
||||||
|
): HTMLElement {
|
||||||
|
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
try {
|
||||||
|
context.updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||||
|
textarea.classList.remove('invalid');
|
||||||
|
context.setFieldError(field.configPath, null);
|
||||||
|
} catch {
|
||||||
|
textarea.classList.add('invalid');
|
||||||
|
context.setFieldError(field.configPath, 'Invalid JSON');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStringListInput(
|
||||||
|
context: SettingsControlContext,
|
||||||
|
field: ConfigSettingsField,
|
||||||
|
value: ConfigSettingsSnapshotValue,
|
||||||
|
): HTMLElement {
|
||||||
|
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
context.updateDraft(
|
||||||
|
field.configPath,
|
||||||
|
textarea.value
|
||||||
|
.split('\n')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ const fields: ConfigSettingsField[] = [
|
|||||||
label: 'Pause on subtitle hover',
|
label: 'Pause on subtitle hover',
|
||||||
description: 'Pause while hovering subtitles.',
|
description: 'Pause while hovering subtitles.',
|
||||||
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
||||||
category: 'viewing',
|
category: 'behavior',
|
||||||
section: 'Playback pause behavior',
|
section: 'Playback Pause Behavior',
|
||||||
control: 'boolean',
|
control: 'boolean',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
restartBehavior: 'hot-reload',
|
restartBehavior: 'hot-reload',
|
||||||
@@ -35,12 +35,12 @@ const fields: ConfigSettingsField[] = [
|
|||||||
|
|
||||||
test('filterSettingsFields searches label, section, and config path', () => {
|
test('filterSettingsFields searches label, section, and config path', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map(
|
filterSettingsFields(fields, { category: 'behavior', query: 'hover' }).map(
|
||||||
(field) => field.configPath,
|
(field) => field.configPath,
|
||||||
),
|
),
|
||||||
['subtitleStyle.autoPauseVideoOnHover'],
|
['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', () => {
|
test('settings draft tracks dirty set and emits save operations', () => {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function filterSettingsFields(
|
|||||||
field.description,
|
field.description,
|
||||||
field.configPath,
|
field.configPath,
|
||||||
field.section,
|
field.section,
|
||||||
|
field.subsection ?? '',
|
||||||
field.enumValues?.join(' ') ?? '',
|
field.enumValues?.join(' ') ?? '',
|
||||||
]
|
]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
|
|||||||
+68
-158
@@ -6,7 +6,12 @@ import type {
|
|||||||
ConfigSettingsSnapshot,
|
ConfigSettingsSnapshot,
|
||||||
ConfigSettingsSnapshotValue,
|
ConfigSettingsSnapshotValue,
|
||||||
} from '../types/settings';
|
} from '../types/settings';
|
||||||
import { parseOptionalNumberInputValue } from './input-values';
|
import {
|
||||||
|
configureSettingsControls,
|
||||||
|
initializeSettingsControls,
|
||||||
|
renderControl,
|
||||||
|
renderNoteFieldModelPicker,
|
||||||
|
} from './settings-controls';
|
||||||
import {
|
import {
|
||||||
createSettingsDraft,
|
createSettingsDraft,
|
||||||
filterSettingsFields,
|
filterSettingsFields,
|
||||||
@@ -23,7 +28,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||||
viewing: 'Viewing',
|
appearance: 'Appearance',
|
||||||
|
behavior: 'Behavior',
|
||||||
'mining-anki': 'Mining & Anki',
|
'mining-anki': 'Mining & Anki',
|
||||||
'playback-sources': 'Playback & Sources',
|
'playback-sources': 'Playback & Sources',
|
||||||
input: 'Input',
|
input: 'Input',
|
||||||
@@ -33,7 +39,8 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||||
'viewing',
|
'appearance',
|
||||||
|
'behavior',
|
||||||
'mining-anki',
|
'mining-anki',
|
||||||
'playback-sources',
|
'playback-sources',
|
||||||
'input',
|
'input',
|
||||||
@@ -51,7 +58,7 @@ const state: {
|
|||||||
} = {
|
} = {
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
draft: null,
|
draft: null,
|
||||||
category: 'viewing',
|
category: 'appearance',
|
||||||
query: '',
|
query: '',
|
||||||
inputErrors: new Map(),
|
inputErrors: new Map(),
|
||||||
};
|
};
|
||||||
@@ -76,12 +83,6 @@ const dom = {
|
|||||||
settingsContent: getElement<HTMLElement>('settingsContent'),
|
settingsContent: getElement<HTMLElement>('settingsContent'),
|
||||||
};
|
};
|
||||||
|
|
||||||
function isSecretSnapshotValue(
|
|
||||||
value: ConfigSettingsSnapshotValue,
|
|
||||||
): value is { configured: boolean } {
|
|
||||||
return Boolean(value && typeof value === 'object' && 'configured' in value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
||||||
dom.statusBanner.textContent = message;
|
dom.statusBanner.textContent = message;
|
||||||
dom.statusBanner.className = `status-banner ${tone}`;
|
dom.statusBanner.className = `status-banner ${tone}`;
|
||||||
@@ -132,7 +133,19 @@ function createFieldMeta(field: ConfigSettingsField): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
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 {
|
function setFieldError(path: string, message: string | null): void {
|
||||||
@@ -150,128 +163,6 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
|||||||
syncSaveButton();
|
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 {
|
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||||
dom.warningsPanel.replaceChildren();
|
dom.warningsPanel.replaceChildren();
|
||||||
if (snapshot.warnings.length === 0) {
|
if (snapshot.warnings.length === 0) {
|
||||||
@@ -330,7 +221,7 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
|||||||
header.append(label, description, createFieldMeta(field));
|
header.append(label, description, createFieldMeta(field));
|
||||||
|
|
||||||
const controlWrap = createElement('div', 'field-control');
|
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;
|
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||||
resetButton.type = 'button';
|
resetButton.type = 'button';
|
||||||
resetButton.textContent = 'Reset';
|
resetButton.textContent = 'Reset';
|
||||||
@@ -374,7 +265,19 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
|||||||
const title = createElement('h2');
|
const title = createElement('h2');
|
||||||
title.textContent = section;
|
title.textContent = section;
|
||||||
sectionEl.append(title);
|
sectionEl.append(title);
|
||||||
|
if (section === 'Note Fields') {
|
||||||
|
sectionEl.append(
|
||||||
|
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let currentSubsection = '';
|
||||||
for (const field of sectionFields) {
|
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));
|
sectionEl.append(renderField(field));
|
||||||
}
|
}
|
||||||
dom.settingsContent.append(sectionEl);
|
dom.settingsContent.append(sectionEl);
|
||||||
@@ -390,11 +293,14 @@ function render(): void {
|
|||||||
syncSaveButton();
|
syncSaveButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureSettingsControls({ requestRender: render });
|
||||||
|
|
||||||
async function loadSnapshot(): Promise<void> {
|
async function loadSnapshot(): Promise<void> {
|
||||||
clearStatus();
|
clearStatus();
|
||||||
const snapshot = await window.configSettingsAPI.getSnapshot();
|
const snapshot = await window.configSettingsAPI.getSnapshot();
|
||||||
state.snapshot = snapshot;
|
state.snapshot = snapshot;
|
||||||
state.draft = createSettingsDraft(snapshot.values);
|
state.draft = createSettingsDraft(snapshot.values);
|
||||||
|
initializeSettingsControls(snapshot.values);
|
||||||
state.inputErrors.clear();
|
state.inputErrors.clear();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -406,34 +312,36 @@ async function save(): Promise<void> {
|
|||||||
|
|
||||||
dom.saveButton.disabled = true;
|
dom.saveButton.disabled = true;
|
||||||
setStatus('Saving...', 'info');
|
setStatus('Saving...', 'info');
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
const result = await window.configSettingsAPI.savePatch({ operations });
|
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();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
||||||
} finally {
|
|
||||||
syncSaveButton();
|
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', () => {
|
dom.searchInput.addEventListener('input', () => {
|
||||||
@@ -444,7 +352,9 @@ dom.saveButton.addEventListener('click', () => {
|
|||||||
void save();
|
void save();
|
||||||
});
|
});
|
||||||
dom.openFileButton.addEventListener('click', () => {
|
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) => {
|
void loadSnapshot().catch((error) => {
|
||||||
|
|||||||
+189
-5
@@ -273,6 +273,13 @@ h1 {
|
|||||||
padding: 7px 10px;
|
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 {
|
.config-textarea {
|
||||||
width: min(420px, 100%);
|
width: min(420px, 100%);
|
||||||
min-height: 138px;
|
min-height: 138px;
|
||||||
@@ -324,7 +331,8 @@ select.config-input option {
|
|||||||
|
|
||||||
.primary-button,
|
.primary-button,
|
||||||
.secondary-button,
|
.secondary-button,
|
||||||
.reset-button {
|
.reset-button,
|
||||||
|
.key-learn-button {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -340,7 +348,8 @@ select.config-input option {
|
|||||||
|
|
||||||
.primary-button:active,
|
.primary-button:active,
|
||||||
.secondary-button:active,
|
.secondary-button:active,
|
||||||
.reset-button:active {
|
.reset-button:active,
|
||||||
|
.key-learn-button:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,19 +374,37 @@ select.config-input option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button,
|
.secondary-button,
|
||||||
.reset-button {
|
.reset-button,
|
||||||
|
.key-learn-button {
|
||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
background: rgba(54, 58, 79, 0.5);
|
background: rgba(54, 58, 79, 0.5);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-button:hover,
|
.secondary-button:hover,
|
||||||
.reset-button:hover {
|
.reset-button:hover,
|
||||||
|
.key-learn-button:hover {
|
||||||
border-color: rgba(138, 173, 244, 0.45);
|
border-color: rgba(138, 173, 244, 0.45);
|
||||||
background: rgba(73, 77, 100, 0.6);
|
background: rgba(73, 77, 100, 0.6);
|
||||||
color: var(--ctp-lavender);
|
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 {
|
.reset-button {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -479,6 +506,18 @@ code {
|
|||||||
text-transform: uppercase;
|
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 {
|
.field-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
|
||||||
@@ -492,6 +531,14 @@ code {
|
|||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-subsection-title + .field-row {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-row {
|
||||||
|
background: rgba(138, 173, 244, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.field-copy h3 {
|
.field-copy h3 {
|
||||||
margin: 0 0 5px;
|
margin: 0 0 5px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -555,6 +602,108 @@ code {
|
|||||||
min-width: 0;
|
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 {
|
.switch-control {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -611,6 +760,39 @@ code {
|
|||||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
|
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 {
|
.empty-state {
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
border: 1px dashed var(--line);
|
border: 1px dashed var(--line);
|
||||||
@@ -638,7 +820,9 @@ code {
|
|||||||
|
|
||||||
.settings-toolbar,
|
.settings-toolbar,
|
||||||
.field-row,
|
.field-row,
|
||||||
.field-control {
|
.field-control,
|
||||||
|
.deck-field-row,
|
||||||
|
.keybinding-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ export const IPC_CHANNELS = {
|
|||||||
saveConfigSettingsPatch: 'config:save-settings-patch',
|
saveConfigSettingsPatch: 'config:save-settings-patch',
|
||||||
openConfigSettingsFile: 'config:open-settings-file',
|
openConfigSettingsFile: 'config:open-settings-file',
|
||||||
openConfigSettingsWindow: 'config:open-settings-window',
|
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: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
|
|||||||
@@ -212,10 +212,8 @@ export interface ResolvedConfig {
|
|||||||
addMinedWordsImmediately: boolean;
|
addMinedWordsImmediately: boolean;
|
||||||
matchMode: NPlusOneMatchMode;
|
matchMode: NPlusOneMatchMode;
|
||||||
decks: Record<string, string[]>;
|
decks: Record<string, string[]>;
|
||||||
color: string;
|
|
||||||
};
|
};
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
nPlusOne: string;
|
|
||||||
minSentenceWords: number;
|
minSentenceWords: number;
|
||||||
};
|
};
|
||||||
behavior: {
|
behavior: {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubtitleSidebarConfig,
|
SubtitleSidebarConfig,
|
||||||
SubtitleSidebarSnapshot,
|
SubtitleSidebarSnapshot,
|
||||||
|
SubtitleRendererStyleConfig,
|
||||||
SubtitleStyleConfig,
|
SubtitleStyleConfig,
|
||||||
} from './subtitle';
|
} from './subtitle';
|
||||||
import type {
|
import type {
|
||||||
@@ -343,7 +344,7 @@ export interface ConfigHotReloadPayload {
|
|||||||
keybindings: Keybinding[];
|
keybindings: Keybinding[];
|
||||||
sessionBindings: CompiledSessionBinding[];
|
sessionBindings: CompiledSessionBinding[];
|
||||||
sessionBindingWarnings: SessionBindingWarning[];
|
sessionBindingWarnings: SessionBindingWarning[];
|
||||||
subtitleStyle: SubtitleStyleConfig | null;
|
subtitleStyle: SubtitleRendererStyleConfig | null;
|
||||||
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
subtitleSidebar: Required<SubtitleSidebarConfig>;
|
||||||
primarySubMode: PrimarySubMode;
|
primarySubMode: PrimarySubMode;
|
||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
@@ -426,7 +427,7 @@ export interface ElectronAPI {
|
|||||||
getSecondarySubMode: () => Promise<SecondarySubMode>;
|
getSecondarySubMode: () => Promise<SecondarySubMode>;
|
||||||
getCurrentSecondarySub: () => Promise<string>;
|
getCurrentSecondarySub: () => Promise<string>;
|
||||||
focusMainWindow: () => Promise<void>;
|
focusMainWindow: () => Promise<void>;
|
||||||
getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>;
|
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
|
||||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||||
|
|||||||
+27
-2
@@ -1,7 +1,8 @@
|
|||||||
import type { ConfigValidationWarning } from './config';
|
import type { ConfigValidationWarning } from './config';
|
||||||
|
|
||||||
export type ConfigSettingsCategory =
|
export type ConfigSettingsCategory =
|
||||||
| 'viewing'
|
| 'appearance'
|
||||||
|
| 'behavior'
|
||||||
| 'mining-anki'
|
| 'mining-anki'
|
||||||
| 'playback-sources'
|
| 'playback-sources'
|
||||||
| 'input'
|
| 'input'
|
||||||
@@ -18,7 +19,14 @@ export type ConfigSettingsControl =
|
|||||||
| 'color'
|
| 'color'
|
||||||
| 'string-list'
|
| 'string-list'
|
||||||
| 'json'
|
| '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';
|
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
|
||||||
|
|
||||||
@@ -29,6 +37,7 @@ export interface ConfigSettingsField {
|
|||||||
configPath: string;
|
configPath: string;
|
||||||
category: ConfigSettingsCategory;
|
category: ConfigSettingsCategory;
|
||||||
section: string;
|
section: string;
|
||||||
|
subsection?: string;
|
||||||
control: ConfigSettingsControl;
|
control: ConfigSettingsControl;
|
||||||
defaultValue: unknown;
|
defaultValue: unknown;
|
||||||
enumValues?: readonly string[];
|
enumValues?: readonly string[];
|
||||||
@@ -77,4 +86,20 @@ export interface ConfigSettingsAPI {
|
|||||||
savePatch(patch: ConfigSettingsPatch): Promise<ConfigSettingsSaveResult>;
|
savePatch(patch: ConfigSettingsPatch): Promise<ConfigSettingsSaveResult>;
|
||||||
openSettingsFile(): Promise<boolean>;
|
openSettingsFile(): Promise<boolean>;
|
||||||
openSettingsWindow(): Promise<boolean>;
|
openSettingsWindow(): Promise<boolean>;
|
||||||
|
getAnkiDeckNames(draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
|
||||||
|
getAnkiDeckFieldNames(
|
||||||
|
deckName: string,
|
||||||
|
draftUrl?: string,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult>;
|
||||||
|
getAnkiModelNames(draftUrl?: string): Promise<ConfigSettingsAnkiListResult>;
|
||||||
|
getAnkiModelFieldNames(
|
||||||
|
modelName: string,
|
||||||
|
draftUrl?: string,
|
||||||
|
): Promise<ConfigSettingsAnkiListResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSettingsAnkiListResult {
|
||||||
|
ok: boolean;
|
||||||
|
values: string[];
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface SubtitleStyleConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubtitleRendererStyleConfig = SubtitleStyleConfig;
|
||||||
|
|
||||||
export interface TokenPos1ExclusionConfig {
|
export interface TokenPos1ExclusionConfig {
|
||||||
defaults?: string[];
|
defaults?: string[];
|
||||||
add?: string[];
|
add?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user