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

- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
This commit is contained in:
2026-05-17 02:10:16 -07:00
parent a54f03f0cd
commit 309ce6ef8f
44 changed files with 2152 additions and 321 deletions
+4
View File
@@ -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.
+4
View File
@@ -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.
+5 -7
View File
@@ -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.
+9 -10
View File
@@ -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.
+5 -7
View File
@@ -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.
+4 -4
View File
@@ -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.
+75
View File
@@ -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] },
});
});
+42
View File
@@ -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>[]) || [];
+9 -9
View File
@@ -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)',
+14 -2
View File
@@ -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);
+2 -13
View File
@@ -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',
+32 -11
View File
@@ -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 (
+32
View File
@@ -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,
) )
+50
View File
@@ -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: {
+64 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+65 -6
View File
@@ -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: () => [],
}); });
+11 -10
View File
@@ -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',
}, },
}); });
-1
View File
@@ -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',
}); });
} }
+13
View File
@@ -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}:`));
}
});
+19
View File
@@ -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);
+2 -2
View File
@@ -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>;
+102
View File
@@ -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, [])),
[],
);
});
+217
View File
@@ -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;
}
+473
View File
@@ -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;
}
+8
View File
@@ -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;
}
+34
View File
@@ -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',
);
}
+202
View File
@@ -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;
}
+4 -4
View File
@@ -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', () => {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+4
View File
@@ -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',
-2
View File
@@ -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: {
+3 -2
View File
@@ -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
View File
@@ -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;
} }
+2
View File
@@ -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[];