mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se
- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window - ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled - Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields - Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Defaulted the note-fields note type picker to `Kiku` when available, then Lapis note types, otherwise leaving it blank for manual selection.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
|
||||||
@@ -360,6 +360,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
@@ -406,6 +407,7 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
@@ -523,6 +525,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": {
|
||||||
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"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).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -530,7 +533,7 @@
|
|||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
|
|||||||
+24
-16
@@ -324,25 +324,29 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
|
||||||
"fontSize": 35,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"fontWeight": "600",
|
|
||||||
"lineHeight": 1.35,
|
|
||||||
"letterSpacing": "-0.01em",
|
|
||||||
"wordSpacing": 0,
|
|
||||||
"fontKerning": "normal",
|
|
||||||
"textRendering": "geometricPrecision",
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
|
||||||
"fontStyle": "normal",
|
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"backdropFilter": "blur(6px)",
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
|
"font-size": "35px",
|
||||||
|
"font-weight": "600",
|
||||||
|
"line-height": "1.35",
|
||||||
|
"letter-spacing": "-0.01em",
|
||||||
|
"word-spacing": "0",
|
||||||
|
"font-kerning": "normal",
|
||||||
|
"text-rendering": "geometricPrecision",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||||
|
"font-style": "normal",
|
||||||
|
"backdrop-filter": "blur(6px)"
|
||||||
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
"backgroundColor": "transparent",
|
||||||
"backgroundColor": "transparent"
|
"css": {
|
||||||
|
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||||
|
"font-size": "24px",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +357,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
|
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||||
@@ -374,7 +379,9 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `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 |
|
||||||
| `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), including `secondary.css` declarations |
|
||||||
|
|
||||||
|
The configuration window keeps subtitle color controls separate, then saves the CSS textbox to `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing top-level style keys such as `fontSize` and `textShadow` remain supported for hand-written configs.
|
||||||
|
|
||||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||||
|
|
||||||
@@ -967,6 +974,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `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.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `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`) |
|
||||||
|
|||||||
@@ -360,6 +360,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
@@ -406,6 +407,7 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
|
"css": {}, // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||||
"fontSize": 24, // Font size setting.
|
"fontSize": 24, // Font size setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"fontColor": "#cad3f5", // Font color setting.
|
||||||
@@ -523,6 +525,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": {
|
||||||
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"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).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -530,7 +533,7 @@
|
|||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
"test:core:dist": "bun test dist/settings/settings-anki-controls.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
|
|||||||
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isKnownWordCacheEnabled(): boolean {
|
private isKnownWordCacheEnabled(): boolean {
|
||||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
const config = this.deps.getConfig();
|
||||||
|
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldAddMinedWordsImmediately(): boolean {
|
private shouldAddMinedWordsImmediately(): boolean {
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||||
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
const wasKnownWordCacheEnabled =
|
||||||
|
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||||
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
||||||
? this.getKnownWordCacheLifecycleConfig(this.config)
|
? this.getKnownWordCacheLifecycleConfig(this.config)
|
||||||
: null;
|
: null;
|
||||||
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
|
|||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
const nextKnownWordCacheEnabled =
|
||||||
|
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||||
|
|
||||||
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
||||||
if (this.started) {
|
if (this.started) {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
autoUpdateNewCards: true,
|
autoUpdateNewCards: true,
|
||||||
},
|
},
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
|
enabled: false,
|
||||||
minSentenceWords: 3,
|
minSentenceWords: 3,
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -76,7 +77,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
isLapis: {
|
isLapis: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
sentenceCardModel: 'Japanese sentences',
|
sentenceCardModel: 'Lapis',
|
||||||
},
|
},
|
||||||
isKiku: {
|
isKiku: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
|
|||||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
primaryDefaultMode: 'visible',
|
primaryDefaultMode: 'visible',
|
||||||
|
css: {},
|
||||||
enableJlpt: false,
|
enableJlpt: false,
|
||||||
preserveLineBreaks: false,
|
preserveLineBreaks: false,
|
||||||
autoPauseVideoOnHover: true,
|
autoPauseVideoOnHover: true,
|
||||||
@@ -43,6 +44,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
|
css: {},
|
||||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontColor: '#cad3f5',
|
fontColor: '#cad3f5',
|
||||||
|
|||||||
@@ -278,6 +278,12 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
|
||||||
description: 'Immediately append newly mined card words into the known-word cache.',
|
description: 'Immediately append newly mined card words into the known-word cache.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.nPlusOne.enabled,
|
||||||
|
description: 'Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
path: 'ankiConnect.nPlusOne.minSentenceWords',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
'Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.css',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.css,
|
||||||
|
description:
|
||||||
|
'CSS declaration object applied to primary subtitles after normal subtitle style defaults.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.secondary.css',
|
||||||
|
kind: 'object',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.secondary.css,
|
||||||
|
description:
|
||||||
|
'CSS declaration object applied to secondary subtitles after normal subtitle style defaults.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.enableJlpt',
|
path: 'subtitleStyle.enableJlpt',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -789,6 +789,21 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
|
||||||
|
if (nPlusOneEnabled !== undefined) {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
|
||||||
|
} else if (nPlusOneConfig.enabled !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.nPlusOne.enabled',
|
||||||
|
nPlusOneConfig.enabled,
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
} else {
|
||||||
|
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
|
||||||
const hasValidNPlusOneMinSentenceWords =
|
const hasValidNPlusOneMinSentenceWords =
|
||||||
nPlusOneMinSentenceWords !== undefined &&
|
nPlusOneMinSentenceWords !== undefined &&
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ import {
|
|||||||
isObject,
|
isObject,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
|
function asCssDeclarations(value: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isObject(value)) return undefined;
|
||||||
|
|
||||||
|
const declarations: Record<string, string> = {};
|
||||||
|
for (const [property, declarationValue] of Object.entries(value)) {
|
||||||
|
if (typeof declarationValue !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (declarationValue.trim().length > 0) {
|
||||||
|
declarations[property] = declarationValue.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
|
||||||
@@ -159,6 +174,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||||
|
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
|
||||||
|
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
|
||||||
const fallbackFrequencyDictionary = {
|
const fallbackFrequencyDictionary = {
|
||||||
...resolved.subtitleStyle.frequencyDictionary,
|
...resolved.subtitleStyle.frequencyDictionary,
|
||||||
};
|
};
|
||||||
@@ -211,6 +228,35 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const css = asCssDeclarations((src.subtitleStyle as { css?: unknown }).css);
|
||||||
|
if (css !== undefined) {
|
||||||
|
resolved.subtitleStyle.css = css;
|
||||||
|
} else if ((src.subtitleStyle as { css?: unknown }).css !== undefined) {
|
||||||
|
resolved.subtitleStyle.css = fallbackSubtitleStyleCss;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.css',
|
||||||
|
(src.subtitleStyle as { css?: unknown }).css,
|
||||||
|
resolved.subtitleStyle.css,
|
||||||
|
'Expected an object whose values are CSS declaration strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawSecondary = isObject(src.subtitleStyle.secondary)
|
||||||
|
? (src.subtitleStyle.secondary as { css?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const secondaryCss = asCssDeclarations(rawSecondary?.css);
|
||||||
|
if (secondaryCss !== undefined) {
|
||||||
|
resolved.subtitleStyle.secondary.css = secondaryCss;
|
||||||
|
} else if (rawSecondary?.css !== undefined) {
|
||||||
|
resolved.subtitleStyle.secondary.css = fallbackSubtitleStyleSecondaryCss;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
|
rawSecondary.css,
|
||||||
|
resolved.subtitleStyle.secondary.css,
|
||||||
|
'Expected an object whose values are CSS declaration strings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const preserveLineBreaks = asBoolean(
|
const preserveLineBreaks = asBoolean(
|
||||||
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,46 @@ test('subtitleStyle preserveLineBreaks falls back while merge is preserved', ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle css declarations accept string declaration maps and warn on invalid values', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
css: {
|
||||||
|
'font-size': '42px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
css: {
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
|
||||||
|
'font-size': '42px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
});
|
||||||
|
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
css: {
|
||||||
|
'font-size': 42,
|
||||||
|
} as never,
|
||||||
|
secondary: {
|
||||||
|
css: 'font-size: 28px;' as never,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.deepEqual(invalid.context.resolved.subtitleStyle.css, {});
|
||||||
|
assert.deepEqual(invalid.context.resolved.subtitleStyle.secondary.css, {});
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.css'));
|
||||||
|
assert.ok(invalid.warnings.some((warning) => warning.path === 'subtitleStyle.secondary.css'));
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
test('subtitleStyle autoPauseVideoOnHover falls back on invalid value', () => {
|
||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
subtitleStyle: {
|
subtitleStyle: {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function buildConfigSettingsSnapshot(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
|
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,17 +28,60 @@ test('settings registry groups annotation display fields by config group', () =>
|
|||||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
|
||||||
|
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.decks').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.decks').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.matchMode').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.matchMode').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.refreshMinutes').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.knownWords.refreshMinutes').section, 'Known Words');
|
||||||
|
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').category, 'behavior');
|
||||||
|
assert.equal(field('ankiConnect.nPlusOne.minSentenceWords').section, 'N+1');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').category, 'behavior');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.sourcePath').section, 'Frequency Highlighting');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.mode').category, 'behavior');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.matchMode').category, 'behavior');
|
||||||
|
assert.equal(field('subtitleStyle.frequencyDictionary.topX').category, 'behavior');
|
||||||
|
});
|
||||||
|
|
||||||
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
test('settings registry exposes specialized controls for config-assisted inputs', () => {
|
||||||
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
|
||||||
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
|
||||||
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
|
||||||
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
assert.equal(field('keybindings').control, 'mpv-keybindings');
|
||||||
|
assert.equal(field('subtitleStyle.css').control, 'css-declarations');
|
||||||
|
assert.equal(field('subtitleStyle.secondary.css').control, 'css-declarations');
|
||||||
assert.equal(field('shortcuts.copySubtitle').control, 'keyboard-shortcut');
|
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('stats.toggleKey').control, 'key-code');
|
||||||
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
assert.equal(field('discordPresence.presenceStyle').control, 'select');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes css declaration editor for primary and secondary subtitle appearance', () => {
|
||||||
|
const primaryVisible = fields
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.section === 'Primary Subtitle Appearance' && !candidate.settingsHidden,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.configPath);
|
||||||
|
const secondaryVisible = fields
|
||||||
|
.filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.section === 'Secondary Subtitle Appearance' && !candidate.settingsHidden,
|
||||||
|
)
|
||||||
|
.map((candidate) => candidate.configPath);
|
||||||
|
|
||||||
|
assert.deepEqual(primaryVisible, ['subtitleStyle.css']);
|
||||||
|
assert.deepEqual(secondaryVisible, ['subtitleStyle.secondary.css']);
|
||||||
|
assert.equal(field('subtitleStyle.fontSize').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.secondary.fontSize').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.fontColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.backgroundColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.hoverTokenColor').settingsHidden, true);
|
||||||
|
assert.equal(field('subtitleStyle.hoverTokenBackgroundColor').settingsHidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||||
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
|
||||||
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
assert.equal(ankiConnect[0]?.configPath, 'ankiConnect.enabled');
|
||||||
@@ -48,7 +91,7 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
|
|||||||
);
|
);
|
||||||
|
|
||||||
const kikuLapis = fields.filter(
|
const kikuLapis = fields.filter(
|
||||||
(candidate) => candidate.section === 'Kiku Features And Lapis Features',
|
(candidate) => candidate.section === 'Kiku/Lapis Features',
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
|
||||||
@@ -68,6 +111,8 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
|||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
'jellyfin.deviceId',
|
||||||
'jellyfin.clientName',
|
'jellyfin.clientName',
|
||||||
|
'subtitleSidebar.toggleKey',
|
||||||
|
'jellyfin.recentServers',
|
||||||
]) {
|
]) {
|
||||||
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-13
@@ -6,6 +6,10 @@ import type {
|
|||||||
ConfigSettingsRestartBehavior,
|
ConfigSettingsRestartBehavior,
|
||||||
} from '../../types/settings';
|
} from '../../types/settings';
|
||||||
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
|
||||||
|
import {
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssScopeForPath,
|
||||||
|
} from '../../settings/subtitle-style-css';
|
||||||
|
|
||||||
type Leaf = {
|
type Leaf = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -67,6 +71,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
'jellyfin.deviceId',
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
|
'subtitleSidebar.toggleKey',
|
||||||
|
'jellyfin.recentServers',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
|
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
|
||||||
@@ -76,11 +82,19 @@ const JSON_OBJECT_FIELDS = new Set([
|
|||||||
'controller.bindings',
|
'controller.bindings',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'ankiConnect.knownWords.decks',
|
'ankiConnect.knownWords.decks',
|
||||||
|
'subtitleStyle.css',
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
|
||||||
|
|
||||||
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor', 'nPlusOne']);
|
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor', 'nPlusOne']);
|
||||||
|
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
|
||||||
|
...getSubtitleCssManagedConfigPaths('primary'),
|
||||||
|
...getSubtitleCssManagedConfigPaths('secondary'),
|
||||||
|
'subtitleStyle.hoverTokenColor',
|
||||||
|
'subtitleStyle.hoverTokenBackgroundColor',
|
||||||
|
]);
|
||||||
|
|
||||||
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]));
|
||||||
|
|
||||||
@@ -106,7 +120,7 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'Subtitle Sidebar Behavior',
|
'Subtitle Sidebar Behavior',
|
||||||
'Note Fields',
|
'Note Fields',
|
||||||
'Media Capture',
|
'Media Capture',
|
||||||
'Kiku Features And Lapis Features',
|
'Kiku/Lapis Features',
|
||||||
'Anki AI',
|
'Anki AI',
|
||||||
'AnkiConnect Proxy',
|
'AnkiConnect Proxy',
|
||||||
'AnkiConnect',
|
'AnkiConnect',
|
||||||
@@ -123,20 +137,48 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'ankiConnect.proxy.enabled',
|
'ankiConnect.proxy.enabled',
|
||||||
'ankiConnect.isLapis.enabled',
|
'ankiConnect.isLapis.enabled',
|
||||||
'ankiConnect.isKiku.enabled',
|
'ankiConnect.isKiku.enabled',
|
||||||
|
'subtitleStyle.fontColor',
|
||||||
|
'subtitleStyle.backgroundColor',
|
||||||
|
'subtitleStyle.hoverTokenColor',
|
||||||
|
'subtitleStyle.hoverTokenBackgroundColor',
|
||||||
|
'subtitleStyle.css',
|
||||||
|
'subtitleStyle.secondary.fontColor',
|
||||||
|
'subtitleStyle.secondary.backgroundColor',
|
||||||
|
'subtitleStyle.secondary.css',
|
||||||
|
'secondarySub.defaultMode',
|
||||||
|
'secondarySub.secondarySubLanguages',
|
||||||
].map((path, index) => [path, index]),
|
].map((path, index) => [path, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const SUBSECTION_ORDER = new Map<string, number>(
|
const SUBSECTION_ORDER = new Map<string, number>(
|
||||||
['Known Words', 'N+1', 'JLPT', 'Frequency Dictionary', 'Character Names'].map(
|
[
|
||||||
(subsection, index) => [subsection, index],
|
'Known Words',
|
||||||
),
|
'N+1',
|
||||||
|
'JLPT',
|
||||||
|
'Frequency Highlighting',
|
||||||
|
'Character Names',
|
||||||
|
'Mining & Clipboard',
|
||||||
|
'Toggle & Visibility',
|
||||||
|
'Open Panels',
|
||||||
|
'Playback',
|
||||||
|
'Timing',
|
||||||
|
'Default Fold State',
|
||||||
|
].map((subsection, index) => [subsection, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const LABEL_OVERRIDES: Record<string, string> = {
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
'ankiConnect.knownWords.highlightEnabled': 'Enabled',
|
||||||
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
|
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||||
|
'subtitleStyle.css': 'CSS Declarations',
|
||||||
|
'subtitleStyle.secondary.css': 'CSS Declarations',
|
||||||
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
|
||||||
'subtitlePosition.yPercent': 'Subtitle Position',
|
'subtitlePosition.yPercent': 'Subtitle Position',
|
||||||
};
|
};
|
||||||
@@ -150,6 +192,10 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'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.',
|
'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':
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'subtitleStyle.css':
|
||||||
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
|
'subtitleStyle.secondary.css':
|
||||||
|
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -215,6 +261,28 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
) {
|
) {
|
||||||
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
||||||
}
|
}
|
||||||
|
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||||
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
|
||||||
|
path === 'ankiConnect.knownWords.decks' ||
|
||||||
|
path === 'ankiConnect.knownWords.matchMode' ||
|
||||||
|
path === 'ankiConnect.knownWords.refreshMinutes'
|
||||||
|
) {
|
||||||
|
return { category: 'behavior', section: 'Known Words' };
|
||||||
|
}
|
||||||
|
if (path === 'ankiConnect.nPlusOne.minSentenceWords') {
|
||||||
|
return { category: 'behavior', section: 'N+1' };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.matchMode' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.mode' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.sourcePath' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.topX'
|
||||||
|
) {
|
||||||
|
return { category: 'behavior', section: 'Frequency Highlighting' };
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
path.startsWith('ankiConnect.knownWords.') ||
|
path.startsWith('ankiConnect.knownWords.') ||
|
||||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||||
@@ -243,7 +311,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
'subtitleSidebar.autoOpen',
|
'subtitleSidebar.autoOpen',
|
||||||
'subtitleSidebar.autoScroll',
|
'subtitleSidebar.autoScroll',
|
||||||
'subtitleSidebar.layout',
|
'subtitleSidebar.layout',
|
||||||
'subtitleSidebar.toggleKey',
|
|
||||||
]);
|
]);
|
||||||
return sidebarBehaviorPaths.has(path)
|
return sidebarBehaviorPaths.has(path)
|
||||||
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
|
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
|
||||||
@@ -259,7 +326,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
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 Features And Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/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' };
|
||||||
@@ -279,6 +346,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
) {
|
) {
|
||||||
return { category: 'playback-sources', section: topSection(path) };
|
return { category: 'playback-sources', section: topSection(path) };
|
||||||
}
|
}
|
||||||
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
|
}
|
||||||
if (path.startsWith('shortcuts.')) {
|
if (path.startsWith('shortcuts.')) {
|
||||||
return { category: 'input', section: 'Overlay Shortcuts' };
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
@@ -346,6 +416,7 @@ 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 (getSubtitleCssScopeForPath(path)) return 'css-declarations';
|
||||||
if (path === 'keybindings') return 'mpv-keybindings';
|
if (path === 'keybindings') return 'mpv-keybindings';
|
||||||
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
|
||||||
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
if (path === 'ankiConnect.isLapis.sentenceCardModel') return 'anki-note-type';
|
||||||
@@ -376,17 +447,71 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subsectionForPath(path: string): string | undefined {
|
function subsectionForPath(path: string): string | undefined {
|
||||||
if (path.startsWith('ankiConnect.knownWords.')) return 'Known Words';
|
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
|
||||||
if (path.startsWith('ankiConnect.nPlusOne.')) return 'N+1';
|
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
|
||||||
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
||||||
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
||||||
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
|
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
|
||||||
return 'JLPT';
|
return 'JLPT';
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleStyle.frequencyDictionary.')) return 'Frequency Dictionary';
|
if (
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.enabled' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
|
||||||
|
path === 'subtitleStyle.frequencyDictionary.bandedColors'
|
||||||
|
) {
|
||||||
|
return 'Frequency Highlighting';
|
||||||
|
}
|
||||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
||||||
return 'Character Names';
|
return 'Character Names';
|
||||||
}
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.characterInformation') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'anilist.characterDictionary.collapsibleSections.voicedBy') {
|
||||||
|
return 'Default Fold State';
|
||||||
|
}
|
||||||
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
|
return 'Toggle & Visibility';
|
||||||
|
}
|
||||||
|
if (path.startsWith('shortcuts.')) {
|
||||||
|
const leaf = path.split('.').at(-1) ?? '';
|
||||||
|
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
|
||||||
|
if (
|
||||||
|
leaf === 'copySubtitle' ||
|
||||||
|
leaf === 'copySubtitleMultiple' ||
|
||||||
|
leaf === 'mineSentence' ||
|
||||||
|
leaf === 'mineSentenceMultiple' ||
|
||||||
|
leaf === 'updateLastCardFromClipboard' ||
|
||||||
|
leaf === 'triggerFieldGrouping' ||
|
||||||
|
leaf === 'markAudioCard'
|
||||||
|
) {
|
||||||
|
return 'Mining & Clipboard';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||||
|
leaf === 'toggleSubtitleSidebar' ||
|
||||||
|
leaf === 'toggleSecondarySub' ||
|
||||||
|
leaf === 'toggleStatsOverlay' ||
|
||||||
|
leaf === 'markWatched'
|
||||||
|
) {
|
||||||
|
return 'Toggle & Visibility';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
leaf === 'openCharacterDictionary' ||
|
||||||
|
leaf === 'openRuntimeOptions' ||
|
||||||
|
leaf === 'openJimaku' ||
|
||||||
|
leaf === 'openSessionHelp' ||
|
||||||
|
leaf === 'openControllerSelect' ||
|
||||||
|
leaf === 'openControllerDebug'
|
||||||
|
) {
|
||||||
|
return 'Open Panels';
|
||||||
|
}
|
||||||
|
if (leaf === 'triggerSubsync') return 'Playback';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +543,9 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
|
|||||||
const sectionName = a.section.localeCompare(b.section);
|
const sectionName = a.section.localeCompare(b.section);
|
||||||
if (sectionName !== 0) return sectionName;
|
if (sectionName !== 0) return sectionName;
|
||||||
|
|
||||||
const subsection =
|
const aSubOrder = a.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||||
(SUBSECTION_ORDER.get(a.subsection ?? '') ?? Number.MAX_SAFE_INTEGER) -
|
const bSubOrder = b.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
|
||||||
(SUBSECTION_ORDER.get(b.subsection ?? '') ?? Number.MAX_SAFE_INTEGER);
|
const subsection = aSubOrder - bSubOrder;
|
||||||
if (subsection !== 0) return subsection;
|
if (subsection !== 0) return subsection;
|
||||||
|
|
||||||
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
|
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
|
||||||
@@ -446,7 +571,9 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
pathStartsWith(path, 'subtitleStyle') ||
|
pathStartsWith(path, 'subtitleStyle') ||
|
||||||
pathStartsWith(path, 'subtitleSidebar') ||
|
pathStartsWith(path, 'subtitleSidebar') ||
|
||||||
path === 'secondarySub.defaultMode' ||
|
path === 'secondarySub.defaultMode' ||
|
||||||
pathStartsWith(path, 'ankiConnect.ai')
|
pathStartsWith(path, 'ankiConnect.ai') ||
|
||||||
|
path === 'stats.toggleKey' ||
|
||||||
|
path === 'stats.markWatchedKey'
|
||||||
) {
|
) {
|
||||||
return 'hot-reload';
|
return 'hot-reload';
|
||||||
}
|
}
|
||||||
@@ -474,6 +601,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
|
|||||||
leaf.path.startsWith('immersionTracking.retention.') ||
|
leaf.path.startsWith('immersionTracking.retention.') ||
|
||||||
leaf.path.startsWith('youtubeSubgen.'),
|
leaf.path.startsWith('youtubeSubgen.'),
|
||||||
secret: SECRET_PATHS.has(leaf.path),
|
secret: SECRET_PATHS.has(leaf.path),
|
||||||
|
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2576,7 +2576,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
|
|||||||
const config = getResolvedConfig();
|
const config = getResolvedConfig();
|
||||||
const nPlusOneEnabled = getRuntimeBooleanOption(
|
const nPlusOneEnabled = getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
config.ankiConnect.knownWords.highlightEnabled,
|
config.ankiConnect.nPlusOne.enabled,
|
||||||
);
|
);
|
||||||
const jlptEnabled = getRuntimeBooleanOption(
|
const jlptEnabled = getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.jlpt',
|
'subtitle.annotation.jlpt',
|
||||||
@@ -4187,7 +4187,7 @@ const {
|
|||||||
getNPlusOneEnabled: () =>
|
getNPlusOneEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
getResolvedConfig().ankiConnect.nPlusOne.enabled,
|
||||||
),
|
),
|
||||||
getMinSentenceWordsForNPlusOne: () =>
|
getMinSentenceWordsForNPlusOne: () =>
|
||||||
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||||
|
|||||||
@@ -426,6 +426,55 @@ test('applySubtitleStyle stores secondary background styles in hover-aware css v
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applySubtitleStyle applies primary and secondary css declaration objects', () => {
|
||||||
|
const restoreDocument = installFakeDocument();
|
||||||
|
try {
|
||||||
|
const subtitleRoot = new FakeElement('div');
|
||||||
|
const subtitleContainer = new FakeElement('div');
|
||||||
|
const secondarySubRoot = new FakeElement('div');
|
||||||
|
const secondarySubContainer = new FakeElement('div');
|
||||||
|
const ctx = {
|
||||||
|
state: createRendererState(),
|
||||||
|
dom: {
|
||||||
|
subtitleRoot,
|
||||||
|
subtitleContainer,
|
||||||
|
secondarySubRoot,
|
||||||
|
secondarySubContainer,
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const renderer = createSubtitleRenderer(ctx);
|
||||||
|
renderer.applySubtitleStyle({
|
||||||
|
fontSize: 35,
|
||||||
|
css: {
|
||||||
|
'font-size': '42px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
'--subtitle-outline': '1px',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
fontSize: 24,
|
||||||
|
css: {
|
||||||
|
'font-size': '28px',
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
|
||||||
|
.values;
|
||||||
|
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
|
||||||
|
.values;
|
||||||
|
|
||||||
|
assert.equal(primaryValues?.get('font-size'), '42px');
|
||||||
|
assert.equal(primaryValues?.get('text-wrap'), 'balance');
|
||||||
|
assert.equal(primaryValues?.get('--subtitle-outline'), '1px');
|
||||||
|
assert.equal(secondaryValues?.get('font-size'), '28px');
|
||||||
|
assert.equal(secondaryValues?.get('text-transform'), 'uppercase');
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
|
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
|
||||||
const restoreDocument = installFakeDocument();
|
const restoreDocument = installFakeDocument();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -158,6 +158,32 @@ function applyInlineStyleDeclarations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCssDeclarationObject(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations: Record<string, string> = {};
|
||||||
|
for (const [key, rawValue] of Object.entries(value)) {
|
||||||
|
if (typeof rawValue !== 'string') continue;
|
||||||
|
const cssValue = rawValue.trim();
|
||||||
|
if (cssValue.length > 0) declarations[key] = cssValue;
|
||||||
|
}
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySubtitleCssDeclarations(
|
||||||
|
root: HTMLElement,
|
||||||
|
container: HTMLElement,
|
||||||
|
declarations: Record<string, string>,
|
||||||
|
): void {
|
||||||
|
applyInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
|
||||||
|
applyInlineStyleDeclarations(
|
||||||
|
container,
|
||||||
|
pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function pickInlineStyleDeclarations(
|
function pickInlineStyleDeclarations(
|
||||||
declarations: Record<string, unknown>,
|
declarations: Record<string, unknown>,
|
||||||
includedKeys: ReadonlySet<string>,
|
includedKeys: ReadonlySet<string>,
|
||||||
@@ -172,7 +198,9 @@ function pickInlineStyleDeclarations(
|
|||||||
|
|
||||||
const CONTAINER_STYLE_KEYS = new Set<string>([
|
const CONTAINER_STYLE_KEYS = new Set<string>([
|
||||||
'background',
|
'background',
|
||||||
|
'background-color',
|
||||||
'backgroundColor',
|
'backgroundColor',
|
||||||
|
'backdrop-filter',
|
||||||
'backdropFilter',
|
'backdropFilter',
|
||||||
'WebkitBackdropFilter',
|
'WebkitBackdropFilter',
|
||||||
'webkitBackdropFilter',
|
'webkitBackdropFilter',
|
||||||
@@ -180,7 +208,7 @@ const CONTAINER_STYLE_KEYS = new Set<string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string {
|
function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>): string {
|
||||||
for (const key of ['backgroundColor', 'background']) {
|
for (const key of ['backgroundColor', 'background-color', 'background']) {
|
||||||
const value = declarations[key];
|
const value = declarations[key];
|
||||||
if (typeof value === 'string' && value.trim().length > 0) {
|
if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
return value.trim();
|
return value.trim();
|
||||||
@@ -193,6 +221,7 @@ function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>):
|
|||||||
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
|
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
|
||||||
for (const key of [
|
for (const key of [
|
||||||
'backdropFilter',
|
'backdropFilter',
|
||||||
|
'backdrop-filter',
|
||||||
'WebkitBackdropFilter',
|
'WebkitBackdropFilter',
|
||||||
'webkitBackdropFilter',
|
'webkitBackdropFilter',
|
||||||
'-webkit-backdrop-filter',
|
'-webkit-backdrop-filter',
|
||||||
@@ -762,20 +791,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
'--subtitle-frequency-band-5-color',
|
'--subtitle-frequency-band-5-color',
|
||||||
frequencyBandedColors[4],
|
frequencyBandedColors[4],
|
||||||
);
|
);
|
||||||
|
applySubtitleCssDeclarations(
|
||||||
|
ctx.dom.subtitleRoot,
|
||||||
|
ctx.dom.subtitleContainer,
|
||||||
|
normalizeCssDeclarationObject(style.css),
|
||||||
|
);
|
||||||
|
|
||||||
const secondaryStyle = style.secondary;
|
const secondaryStyle = style.secondary;
|
||||||
if (!secondaryStyle) return;
|
if (!secondaryStyle) return;
|
||||||
|
|
||||||
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
|
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
|
||||||
|
const secondaryCssDeclarations = normalizeCssDeclarationObject(secondaryStyle.css);
|
||||||
applyInlineStyleDeclarations(
|
applyInlineStyleDeclarations(
|
||||||
ctx.dom.secondarySubRoot,
|
ctx.dom.secondarySubRoot,
|
||||||
secondaryStyleDeclarations,
|
secondaryStyleDeclarations,
|
||||||
CONTAINER_STYLE_KEYS,
|
CONTAINER_STYLE_KEYS,
|
||||||
);
|
);
|
||||||
const secondaryContainerStyleDeclarations = pickInlineStyleDeclarations(
|
const secondaryContainerStyleDeclarations = {
|
||||||
secondaryStyleDeclarations,
|
...pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
|
||||||
CONTAINER_STYLE_KEYS,
|
...pickInlineStyleDeclarations(secondaryCssDeclarations, CONTAINER_STYLE_KEYS),
|
||||||
);
|
};
|
||||||
ctx.dom.secondarySubContainer.style.setProperty(
|
ctx.dom.secondarySubContainer.style.setProperty(
|
||||||
'--secondary-sub-background-color',
|
'--secondary-sub-background-color',
|
||||||
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
|
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
|
||||||
@@ -800,6 +835,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
|||||||
if (secondaryStyle.fontStyle) {
|
if (secondaryStyle.fontStyle) {
|
||||||
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
|
||||||
}
|
}
|
||||||
|
applySubtitleCssDeclarations(
|
||||||
|
ctx.dom.secondarySubRoot,
|
||||||
|
ctx.dom.secondarySubContainer,
|
||||||
|
secondaryCssDeclarations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
aria-label="Search settings"
|
aria-label="Search settings"
|
||||||
/>
|
/>
|
||||||
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
|
|
||||||
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as ankiControls from './settings-anki-controls';
|
||||||
|
|
||||||
|
test('note field model preference chooses Kiku before configured Lapis default', () => {
|
||||||
|
assert.equal(
|
||||||
|
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
|
||||||
|
'Kiku',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('note field model preference falls back to Lapis when Kiku is unavailable', () => {
|
||||||
|
assert.equal(
|
||||||
|
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], 'Lapis Morph'),
|
||||||
|
'Lapis Morph',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('note field model preference does not treat partial Kiku matches as Kiku', () => {
|
||||||
|
assert.equal(
|
||||||
|
ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Lapis Morph'], 'Lapis Morph'),
|
||||||
|
'Lapis Morph',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('note field model preference stays blank when no Kiku or Lapis note type exists', () => {
|
||||||
|
assert.equal(
|
||||||
|
ankiControls.selectPreferredNoteFieldModelName(['Basic', 'Mining'], 'Lapis Morph'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ const state: {
|
|||||||
modelFieldNamesErrors: Map<string, string>;
|
modelFieldNamesErrors: Map<string, string>;
|
||||||
noteFieldModelName: string;
|
noteFieldModelName: string;
|
||||||
ankiConnectUrl: string;
|
ankiConnectUrl: string;
|
||||||
|
noteFieldModelNameManuallySelected: boolean;
|
||||||
} = {
|
} = {
|
||||||
deckNames: null,
|
deckNames: null,
|
||||||
deckNamesLoading: false,
|
deckNamesLoading: false,
|
||||||
@@ -32,6 +33,7 @@ const state: {
|
|||||||
modelFieldNamesErrors: new Map(),
|
modelFieldNamesErrors: new Map(),
|
||||||
noteFieldModelName: '',
|
noteFieldModelName: '',
|
||||||
ankiConnectUrl: '',
|
ankiConnectUrl: '',
|
||||||
|
noteFieldModelNameManuallySelected: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let requestRender = (): void => undefined;
|
let requestRender = (): void => undefined;
|
||||||
@@ -42,11 +44,32 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
|
|||||||
|
|
||||||
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
|
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
|
||||||
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
|
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
|
||||||
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') {
|
if (
|
||||||
|
!state.noteFieldModelName &&
|
||||||
|
!state.noteFieldModelNameManuallySelected &&
|
||||||
|
typeof configuredNoteType === 'string'
|
||||||
|
) {
|
||||||
state.noteFieldModelName = configuredNoteType;
|
state.noteFieldModelName = configuredNoteType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectPreferredNoteFieldModelName(
|
||||||
|
modelNames: readonly string[],
|
||||||
|
_currentModelName = '',
|
||||||
|
): string {
|
||||||
|
const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku');
|
||||||
|
if (exactKiku) {
|
||||||
|
return exactKiku;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
|
||||||
|
if (lapis) {
|
||||||
|
return lapis;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStringArray(value: unknown): string[] {
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
return Array.isArray(value)
|
return Array.isArray(value)
|
||||||
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
||||||
@@ -168,8 +191,11 @@ async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
state.modelNames = uniqueSorted(result.values);
|
state.modelNames = uniqueSorted(result.values);
|
||||||
state.modelNamesError = null;
|
state.modelNamesError = null;
|
||||||
if (!state.noteFieldModelName && state.modelNames[0]) {
|
if (!state.noteFieldModelNameManuallySelected) {
|
||||||
state.noteFieldModelName = state.modelNames[0];
|
state.noteFieldModelName = selectPreferredNoteFieldModelName(
|
||||||
|
state.modelNames,
|
||||||
|
state.noteFieldModelName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.modelNames = [];
|
state.modelNames = [];
|
||||||
@@ -318,6 +344,7 @@ export function renderNoteFieldModelPicker(context: SettingsControlContext): HTM
|
|||||||
select.value = state.noteFieldModelName;
|
select.value = state.noteFieldModelName;
|
||||||
select.addEventListener('change', () => {
|
select.addEventListener('change', () => {
|
||||||
state.noteFieldModelName = select.value;
|
state.noteFieldModelName = select.value;
|
||||||
|
state.noteFieldModelNameManuallySelected = true;
|
||||||
requestRender();
|
requestRender();
|
||||||
});
|
});
|
||||||
control.append(select);
|
control.append(select);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/
|
|||||||
|
|
||||||
export interface SettingsControlContext {
|
export interface SettingsControlContext {
|
||||||
setFieldError(path: string, message: string | null): void;
|
setFieldError(path: string, message: string | null): void;
|
||||||
|
resetDraftPath(path: string, defaultValue?: ConfigSettingsSnapshotValue): void;
|
||||||
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
|
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
|
||||||
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
|
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
|
||||||
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
|
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
|
||||||
|
|||||||
@@ -10,12 +10,23 @@ import {
|
|||||||
} from './settings-anki-controls';
|
} from './settings-anki-controls';
|
||||||
import type { SettingsControlContext } from './settings-control-context';
|
import type { SettingsControlContext } from './settings-control-context';
|
||||||
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
|
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
|
||||||
import { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls';
|
import {
|
||||||
|
configureKeybindingControls,
|
||||||
|
renderKeyboardInput,
|
||||||
|
renderMpvKeybindingsInput,
|
||||||
|
} from './settings-keybinding-controls';
|
||||||
|
import {
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
getSubtitleCssScopeForPath,
|
||||||
|
parseSubtitleCssDeclarations,
|
||||||
|
serializeSubtitleCssDeclarations,
|
||||||
|
} from './subtitle-style-css';
|
||||||
|
|
||||||
export { renderNoteFieldModelPicker };
|
export { renderNoteFieldModelPicker };
|
||||||
|
|
||||||
export function configureSettingsControls(options: { requestRender: () => void }): void {
|
export function configureSettingsControls(options: { requestRender: () => void }): void {
|
||||||
configureAnkiControls(options);
|
configureAnkiControls(options);
|
||||||
|
configureKeybindingControls(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initializeSettingsControls(
|
export function initializeSettingsControls(
|
||||||
@@ -90,6 +101,44 @@ function renderStringListInput(
|
|||||||
return textarea;
|
return textarea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCssDeclarationsInput(
|
||||||
|
context: SettingsControlContext,
|
||||||
|
field: ConfigSettingsField,
|
||||||
|
): HTMLElement {
|
||||||
|
const scope = getSubtitleCssScopeForPath(field.configPath);
|
||||||
|
const textarea = createElement(
|
||||||
|
'textarea',
|
||||||
|
'config-textarea css-declarations',
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
if (!scope) return textarea;
|
||||||
|
|
||||||
|
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
|
||||||
|
const values: Record<string, ConfigSettingsSnapshotValue | undefined> = {
|
||||||
|
[field.configPath]: context.valueForPath(field.configPath),
|
||||||
|
};
|
||||||
|
for (const path of managedPaths) {
|
||||||
|
values[path] = context.valueForPath(path);
|
||||||
|
}
|
||||||
|
textarea.value = serializeSubtitleCssDeclarations(scope, values);
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
const parsed = parseSubtitleCssDeclarations(textarea.value);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
textarea.classList.add('invalid');
|
||||||
|
context.setFieldError(field.configPath, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.classList.remove('invalid');
|
||||||
|
context.setFieldError(field.configPath, null);
|
||||||
|
context.updateDraft(field.configPath, parsed.declarations);
|
||||||
|
for (const path of managedPaths) {
|
||||||
|
context.resetDraftPath(path, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderControl(
|
export function renderControl(
|
||||||
field: ConfigSettingsField,
|
field: ConfigSettingsField,
|
||||||
context: SettingsControlContext,
|
context: SettingsControlContext,
|
||||||
@@ -134,7 +183,13 @@ export function renderControl(
|
|||||||
if (field.control === 'number') {
|
if (field.control === 'number') {
|
||||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||||
input.type = 'number';
|
input.type = 'number';
|
||||||
input.value = typeof value === 'number' ? String(value) : '';
|
const numericValue =
|
||||||
|
typeof value === 'number'
|
||||||
|
? value
|
||||||
|
: typeof field.defaultValue === 'number'
|
||||||
|
? field.defaultValue
|
||||||
|
: NaN;
|
||||||
|
input.value = Number.isFinite(numericValue) ? String(numericValue) : '';
|
||||||
input.addEventListener('input', () => {
|
input.addEventListener('input', () => {
|
||||||
const next = parseOptionalNumberInputValue(input.value);
|
const next = parseOptionalNumberInputValue(input.value);
|
||||||
if (next.ok) {
|
if (next.ok) {
|
||||||
@@ -174,6 +229,10 @@ export function renderControl(
|
|||||||
return renderJsonInput(context, field, value);
|
return renderJsonInput(context, field, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.control === 'css-declarations') {
|
||||||
|
return renderCssDeclarationsInput(context, field);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.control === 'textarea') {
|
if (field.control === 'textarea') {
|
||||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||||
textarea.spellcheck = false;
|
textarea.spellcheck = false;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import type { SettingsControlContext } from './settings-control-context';
|
|||||||
import { createElement } from './settings-control-dom';
|
import { createElement } from './settings-control-dom';
|
||||||
|
|
||||||
let activeKeyLearningStop: (() => void) | null = null;
|
let activeKeyLearningStop: (() => void) | null = null;
|
||||||
|
let requestRender = (): void => undefined;
|
||||||
|
|
||||||
|
export function configureKeybindingControls(options: { requestRender: () => void }): void {
|
||||||
|
requestRender = options.requestRender;
|
||||||
|
}
|
||||||
|
|
||||||
function startKeyLearning(
|
function startKeyLearning(
|
||||||
button: HTMLButtonElement,
|
button: HTMLButtonElement,
|
||||||
@@ -107,7 +112,8 @@ export function renderMpvKeybindingsInput(
|
|||||||
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
|
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
|
||||||
const container = createElement('div', 'keybinding-editor');
|
const container = createElement('div', 'keybinding-editor');
|
||||||
|
|
||||||
for (const row of rows) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i]!;
|
||||||
const item = createElement('div', 'keybinding-row');
|
const item = createElement('div', 'keybinding-row');
|
||||||
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
|
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
|
||||||
row.key = next;
|
row.key = next;
|
||||||
@@ -130,9 +136,27 @@ export function renderMpvKeybindingsInput(
|
|||||||
row.commandText = command.value;
|
row.commandText = command.value;
|
||||||
applyMpvRows(context, field, rows);
|
applyMpvRows(context, field, rows);
|
||||||
});
|
});
|
||||||
item.append(keyButton, command);
|
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
|
||||||
|
removeButton.type = 'button';
|
||||||
|
removeButton.textContent = 'Remove';
|
||||||
|
removeButton.addEventListener('click', () => {
|
||||||
|
rows.splice(i, 1);
|
||||||
|
applyMpvRows(context, field, rows);
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
item.append(keyButton, command, removeButton);
|
||||||
container.append(item);
|
container.append(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
|
||||||
|
addButton.type = 'button';
|
||||||
|
addButton.textContent = 'Add Binding';
|
||||||
|
addButton.addEventListener('click', () => {
|
||||||
|
rows.push({ defaultKey: '', key: '', command: null, commandText: '', isDefault: false });
|
||||||
|
applyMpvRows(context, field, rows);
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
container.append(addButton);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
test('settings toolbar does not expose an open file button', () => {
|
||||||
|
const html = fs.readFileSync(path.join(process.cwd(), 'src/settings/index.html'), 'utf8');
|
||||||
|
|
||||||
|
assert.equal(html.includes('id="openFileButton"'), false);
|
||||||
|
assert.equal(html.includes('Open File'), false);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createSettingsDraft,
|
createSettingsDraft,
|
||||||
filterSettingsFields,
|
filterSettingsFields,
|
||||||
setDraftValue,
|
setDraftValue,
|
||||||
|
resetDraftPath,
|
||||||
getDirtyOperations,
|
getDirtyOperations,
|
||||||
} from './settings-model';
|
} from './settings-model';
|
||||||
import type { ConfigSettingsField } from '../types/settings';
|
import type { ConfigSettingsField } from '../types/settings';
|
||||||
@@ -31,6 +32,18 @@ const fields: ConfigSettingsField[] = [
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
restartBehavior: 'restart',
|
restartBehavior: 'restart',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitleStyle.fontSize',
|
||||||
|
label: 'Font Size',
|
||||||
|
description: 'Hidden behind CSS editor.',
|
||||||
|
configPath: 'subtitleStyle.fontSize',
|
||||||
|
category: 'appearance',
|
||||||
|
section: 'Primary Subtitle Appearance',
|
||||||
|
control: 'number',
|
||||||
|
defaultValue: 35,
|
||||||
|
restartBehavior: 'hot-reload',
|
||||||
|
settingsHidden: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
test('filterSettingsFields searches label, section, and config path', () => {
|
test('filterSettingsFields searches label, section, and config path', () => {
|
||||||
@@ -41,6 +54,16 @@ test('filterSettingsFields searches label, section, and config path', () => {
|
|||||||
['subtitleStyle.autoPauseVideoOnHover'],
|
['subtitleStyle.autoPauseVideoOnHover'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
|
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterSettingsFields(fields, { query: 'anki' }).map((field) => field.configPath),
|
||||||
|
['ankiConnect.enabled'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
filterSettingsFields(fields, { query: 'pause hover' }).map((field) => field.configPath),
|
||||||
|
['subtitleStyle.autoPauseVideoOnHover'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(filterSettingsFields(fields, { query: 'pause anki' }), []);
|
||||||
|
assert.deepEqual(filterSettingsFields(fields, { category: 'appearance', query: '' }), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings draft tracks dirty set and emits save operations', () => {
|
test('settings draft tracks dirty set and emits save operations', () => {
|
||||||
@@ -60,3 +83,25 @@ test('settings draft tracks dirty set and emits save operations', () => {
|
|||||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
||||||
assert.deepEqual(getDirtyOperations(draft), []);
|
assert.deepEqual(getDirtyOperations(draft), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings draft emits reset operations for css-editor-owned legacy style paths', () => {
|
||||||
|
const draft = createSettingsDraft({
|
||||||
|
'subtitleStyle.css': {},
|
||||||
|
'subtitleStyle.fontSize': 35,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDraftValue(draft, 'subtitleStyle.css', { 'font-size': '42px' });
|
||||||
|
resetDraftPath(draft, 'subtitleStyle.fontSize', undefined);
|
||||||
|
|
||||||
|
assert.deepEqual(getDirtyOperations(draft), [
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'subtitleStyle.css',
|
||||||
|
value: { 'font-size': '42px' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: 'reset',
|
||||||
|
path: 'subtitleStyle.fontSize',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from '../types/settings';
|
} from '../types/settings';
|
||||||
|
|
||||||
export interface SettingsFilter {
|
export interface SettingsFilter {
|
||||||
category: ConfigSettingsCategory;
|
category?: ConfigSettingsCategory;
|
||||||
query?: string;
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,15 @@ function normalizeQuery(query: string | undefined): string {
|
|||||||
return (query ?? '').trim().toLowerCase();
|
return (query ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchableText(parts: Array<string | undefined>): string {
|
||||||
|
return parts
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, ' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
}
|
}
|
||||||
@@ -29,24 +38,26 @@ export function filterSettingsFields(
|
|||||||
filter: SettingsFilter,
|
filter: SettingsFilter,
|
||||||
): ConfigSettingsField[] {
|
): ConfigSettingsField[] {
|
||||||
const query = normalizeQuery(filter.query);
|
const query = normalizeQuery(filter.query);
|
||||||
|
const terms = query.length > 0 ? query.split(/\s+/) : [];
|
||||||
return fields.filter((field) => {
|
return fields.filter((field) => {
|
||||||
if (field.category !== filter.category || field.legacyHidden) {
|
if (field.legacyHidden || field.settingsHidden) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filter.category && field.category !== filter.category) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const haystack = [
|
const haystack = searchableText([
|
||||||
field.label,
|
field.label,
|
||||||
field.description,
|
field.description,
|
||||||
field.configPath,
|
field.configPath,
|
||||||
field.section,
|
field.section,
|
||||||
field.subsection ?? '',
|
field.subsection ?? '',
|
||||||
field.enumValues?.join(' ') ?? '',
|
field.enumValues?.join(' ') ?? '',
|
||||||
]
|
]);
|
||||||
.join(' ')
|
return terms.every((term) => haystack.includes(term));
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(query);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-21
@@ -20,6 +20,7 @@ import {
|
|||||||
setDraftValue,
|
setDraftValue,
|
||||||
type SettingsDraft,
|
type SettingsDraft,
|
||||||
} from './settings-model';
|
} from './settings-model';
|
||||||
|
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -76,7 +77,6 @@ const dom = {
|
|||||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
|
||||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||||
@@ -163,6 +163,13 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
|||||||
syncSaveButton();
|
syncSaveButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetDraftPathContext(path: string, defaultValue?: ConfigSettingsSnapshotValue): void {
|
||||||
|
if (!state.draft) return;
|
||||||
|
resetDraftPath(state.draft, path, defaultValue);
|
||||||
|
state.inputErrors.delete(path);
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
|
||||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||||
dom.warningsPanel.replaceChildren();
|
dom.warningsPanel.replaceChildren();
|
||||||
if (snapshot.warnings.length === 0) {
|
if (snapshot.warnings.length === 0) {
|
||||||
@@ -192,7 +199,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
|||||||
dom.categoryNav.replaceChildren();
|
dom.categoryNav.replaceChildren();
|
||||||
for (const category of CATEGORY_ORDER) {
|
for (const category of CATEGORY_ORDER) {
|
||||||
const count = snapshot.fields.filter(
|
const count = snapshot.fields.filter(
|
||||||
(field) => field.category === category && !field.legacyHidden,
|
(field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
|
||||||
).length;
|
).length;
|
||||||
if (count === 0) continue;
|
if (count === 0) continue;
|
||||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||||
@@ -206,6 +213,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
|||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
state.category = category;
|
state.category = category;
|
||||||
render();
|
render();
|
||||||
|
dom.settingsContent.scrollTop = 0;
|
||||||
});
|
});
|
||||||
dom.categoryNav.append(button);
|
dom.categoryNav.append(button);
|
||||||
}
|
}
|
||||||
@@ -222,7 +230,13 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
|||||||
|
|
||||||
const controlWrap = createElement('div', 'field-control');
|
const controlWrap = createElement('div', 'field-control');
|
||||||
controlWrap.append(
|
controlWrap.append(
|
||||||
renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath }),
|
renderControl(field, {
|
||||||
|
setFieldError,
|
||||||
|
resetDraftPath: resetDraftPathContext,
|
||||||
|
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';
|
||||||
@@ -230,6 +244,12 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
|||||||
resetButton.addEventListener('click', () => {
|
resetButton.addEventListener('click', () => {
|
||||||
if (!state.draft) return;
|
if (!state.draft) return;
|
||||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||||
|
const cssScope = getSubtitleCssScopeForPath(field.configPath);
|
||||||
|
if (cssScope) {
|
||||||
|
for (const path of getSubtitleCssManagedConfigPaths(cssScope)) {
|
||||||
|
resetDraftPath(state.draft, path, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
state.inputErrors.delete(field.configPath);
|
state.inputErrors.delete(field.configPath);
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -240,13 +260,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
|||||||
|
|
||||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||||
dom.settingsContent.replaceChildren();
|
dom.settingsContent.replaceChildren();
|
||||||
|
const query = state.query.trim();
|
||||||
const fields = filterSettingsFields(snapshot.fields, {
|
const fields = filterSettingsFields(snapshot.fields, {
|
||||||
category: state.category,
|
category: query ? undefined : state.category,
|
||||||
query: state.query,
|
query,
|
||||||
});
|
});
|
||||||
|
|
||||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
if (query) {
|
||||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
const categoryCount = new Set(fields.map((field) => field.category)).size;
|
||||||
|
dom.categoryTitle.textContent = 'Search results';
|
||||||
|
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}${
|
||||||
|
categoryCount > 0
|
||||||
|
? ` across ${categoryCount} categor${categoryCount === 1 ? 'y' : 'ies'}`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
} else {
|
||||||
|
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||||
|
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
const empty = createElement('div', 'empty-state');
|
const empty = createElement('div', 'empty-state');
|
||||||
@@ -255,25 +286,35 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections = new Map<string, ConfigSettingsField[]>();
|
const sections = new Map<
|
||||||
|
string,
|
||||||
|
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
|
||||||
|
>();
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const sectionFields = sections.get(field.section) ?? [];
|
const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
|
||||||
sectionFields.push(field);
|
const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
|
||||||
sections.set(field.section, sectionFields);
|
section.fields.push(field);
|
||||||
|
sections.set(title, section);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [section, sectionFields] of sections) {
|
for (const section of sections.values()) {
|
||||||
const sectionEl = createElement('section', 'settings-section');
|
const sectionEl = createElement('section', 'settings-section');
|
||||||
const title = createElement('h2');
|
const title = createElement('h2');
|
||||||
title.textContent = section;
|
title.textContent = section.title;
|
||||||
sectionEl.append(title);
|
sectionEl.append(title);
|
||||||
if (section === 'Note Fields') {
|
if (section.rawSection === 'Note Fields') {
|
||||||
sectionEl.append(
|
sectionEl.append(
|
||||||
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }),
|
renderNoteFieldModelPicker({
|
||||||
|
setFieldError,
|
||||||
|
resetDraftPath: resetDraftPathContext,
|
||||||
|
updateDraft,
|
||||||
|
valueForField,
|
||||||
|
valueForPath,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let currentSubsection = '';
|
let currentSubsection = '';
|
||||||
for (const field of sectionFields) {
|
for (const field of section.fields) {
|
||||||
if (field.subsection && field.subsection !== currentSubsection) {
|
if (field.subsection && field.subsection !== currentSubsection) {
|
||||||
currentSubsection = field.subsection;
|
currentSubsection = field.subsection;
|
||||||
const subsectionTitle = createElement('h3', 'settings-subsection-title');
|
const subsectionTitle = createElement('h3', 'settings-subsection-title');
|
||||||
@@ -353,11 +394,6 @@ dom.searchInput.addEventListener('input', () => {
|
|||||||
dom.saveButton.addEventListener('click', () => {
|
dom.saveButton.addEventListener('click', () => {
|
||||||
void save();
|
void save();
|
||||||
});
|
});
|
||||||
dom.openFileButton.addEventListener('click', () => {
|
|
||||||
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) => {
|
||||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 210px;
|
width: min(360px, 34vw);
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
@@ -296,6 +296,12 @@ h1 {
|
|||||||
min-height: 86px;
|
min-height: 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-textarea.css-declarations {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
min-height: 188px;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input:hover,
|
.search-input:hover,
|
||||||
.config-input:hover,
|
.config-input:hover,
|
||||||
.config-textarea:hover {
|
.config-textarea:hover {
|
||||||
@@ -675,7 +681,7 @@ code {
|
|||||||
|
|
||||||
.keybinding-row {
|
.keybinding-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(146px, 0.78fr) minmax(220px, 1.22fr);
|
grid-template-columns: minmax(146px, 0.78fr) minmax(180px, 1.22fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSubtitleCssManagedConfigPaths,
|
||||||
|
parseSubtitleCssDeclarations,
|
||||||
|
serializeSubtitleCssDeclarations,
|
||||||
|
} from './subtitle-style-css';
|
||||||
|
|
||||||
|
test('serializeSubtitleCssDeclarations builds primary CSS from config minus default colors', () => {
|
||||||
|
const css = serializeSubtitleCssDeclarations('primary', {
|
||||||
|
'subtitleStyle.fontFamily': 'M PLUS 1, sans-serif',
|
||||||
|
'subtitleStyle.fontSize': 35,
|
||||||
|
'subtitleStyle.fontColor': '#cad3f5',
|
||||||
|
'subtitleStyle.backgroundColor': 'transparent',
|
||||||
|
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
|
||||||
|
'subtitleStyle.css': {
|
||||||
|
filter: 'drop-shadow(0 0 8px #000)',
|
||||||
|
'--subtitle-outline': '1px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(css, /font-family: M PLUS 1, sans-serif;/);
|
||||||
|
assert.match(css, /font-size: 35px;/);
|
||||||
|
assert.match(css, /text-shadow: 0 2px 6px rgba\(0,0,0,0.9\);/);
|
||||||
|
assert.match(css, /filter: drop-shadow\(0 0 8px #000\);/);
|
||||||
|
assert.match(css, /--subtitle-outline: 1px;/);
|
||||||
|
assert.doesNotMatch(css, /^color:/m);
|
||||||
|
assert.doesNotMatch(css, /^background-color:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serializeSubtitleCssDeclarations builds secondary CSS from secondary config paths', () => {
|
||||||
|
const css = serializeSubtitleCssDeclarations('secondary', {
|
||||||
|
'subtitleStyle.secondary.fontFamily': 'Noto Sans, sans-serif',
|
||||||
|
'subtitleStyle.secondary.fontSize': 24,
|
||||||
|
'subtitleStyle.secondary.fontColor': '#cad3f5',
|
||||||
|
'subtitleStyle.secondary.backgroundColor': 'transparent',
|
||||||
|
'subtitleStyle.secondary.css': {
|
||||||
|
'text-transform': 'uppercase',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(css, /font-family: Noto Sans, sans-serif;/);
|
||||||
|
assert.match(css, /font-size: 24px;/);
|
||||||
|
assert.match(css, /text-transform: uppercase;/);
|
||||||
|
assert.doesNotMatch(css, /^color:/m);
|
||||||
|
assert.doesNotMatch(css, /^background-color:/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCssDeclarations accepts arbitrary declaration properties', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseSubtitleCssDeclarations(`
|
||||||
|
font-size: 40px;
|
||||||
|
text-wrap: balance;
|
||||||
|
-webkit-text-stroke: 1px black;
|
||||||
|
--subtitle-outline: 1px;
|
||||||
|
`),
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
declarations: {
|
||||||
|
'font-size': '40px',
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
'-webkit-text-stroke': '1px black',
|
||||||
|
'--subtitle-outline': '1px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCssDeclarations rejects selectors and malformed declarations', () => {
|
||||||
|
assert.equal(parseSubtitleCssDeclarations('#subtitleRoot { font-size: 40px; }').ok, false);
|
||||||
|
assert.equal(parseSubtitleCssDeclarations('font-size 40px;').ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSubtitleCssManagedConfigPaths excludes color controls', () => {
|
||||||
|
assert.ok(getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontSize'));
|
||||||
|
assert.ok(
|
||||||
|
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontSize'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getSubtitleCssManagedConfigPaths('primary').includes('subtitleStyle.fontColor'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getSubtitleCssManagedConfigPaths('secondary').includes('subtitleStyle.secondary.fontColor'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type { ConfigSettingsSnapshotValue } from '../types/settings';
|
||||||
|
|
||||||
|
export type SubtitleCssScope = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
type LegacyCssDeclaration = {
|
||||||
|
property: string;
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryPath: string;
|
||||||
|
format?: (value: unknown) => string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubtitleCssParseResult =
|
||||||
|
| { ok: true; declarations: Record<string, string> }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const LEGACY_CSS_DECLARATIONS: LegacyCssDeclaration[] = [
|
||||||
|
{
|
||||||
|
property: 'font-family',
|
||||||
|
primaryPath: 'subtitleStyle.fontFamily',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontFamily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'font-size',
|
||||||
|
primaryPath: 'subtitleStyle.fontSize',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontSize',
|
||||||
|
format: formatCssLengthLikeValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'font-weight',
|
||||||
|
primaryPath: 'subtitleStyle.fontWeight',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontWeight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'font-style',
|
||||||
|
primaryPath: 'subtitleStyle.fontStyle',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontStyle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'line-height',
|
||||||
|
primaryPath: 'subtitleStyle.lineHeight',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.lineHeight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'letter-spacing',
|
||||||
|
primaryPath: 'subtitleStyle.letterSpacing',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.letterSpacing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'word-spacing',
|
||||||
|
primaryPath: 'subtitleStyle.wordSpacing',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.wordSpacing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'font-kerning',
|
||||||
|
primaryPath: 'subtitleStyle.fontKerning',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontKerning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'text-rendering',
|
||||||
|
primaryPath: 'subtitleStyle.textRendering',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.textRendering',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'text-shadow',
|
||||||
|
primaryPath: 'subtitleStyle.textShadow',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.textShadow',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'backdrop-filter',
|
||||||
|
primaryPath: 'subtitleStyle.backdropFilter',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.backdropFilter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'color',
|
||||||
|
primaryPath: 'subtitleStyle.fontColor',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.fontColor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'background-color',
|
||||||
|
primaryPath: 'subtitleStyle.backgroundColor',
|
||||||
|
secondaryPath: 'subtitleStyle.secondary.backgroundColor',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CSS_PROPERTY_PATTERN = /^(?:--[A-Za-z0-9_-]+|-?[A-Za-z][A-Za-z0-9_-]*)$/;
|
||||||
|
|
||||||
|
export function getSubtitleCssPath(scope: SubtitleCssScope): string {
|
||||||
|
return scope === 'primary' ? 'subtitleStyle.css' : 'subtitleStyle.secondary.css';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtitleCssManagedConfigPaths(scope: SubtitleCssScope): string[] {
|
||||||
|
return LEGACY_CSS_DECLARATIONS.map((declaration) =>
|
||||||
|
scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtitleCssScopeForPath(path: string): SubtitleCssScope | null {
|
||||||
|
if (path === 'subtitleStyle.css') return 'primary';
|
||||||
|
if (path === 'subtitleStyle.secondary.css') return 'secondary';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSubtitleCssDeclarations(
|
||||||
|
scope: SubtitleCssScope,
|
||||||
|
values: Record<string, ConfigSettingsSnapshotValue | undefined>,
|
||||||
|
): string {
|
||||||
|
const declarations = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const declaration of LEGACY_CSS_DECLARATIONS) {
|
||||||
|
const path = scope === 'primary' ? declaration.primaryPath : declaration.secondaryPath;
|
||||||
|
const formatted = (declaration.format ?? formatCssPrimitiveValue)(values[path]);
|
||||||
|
if (formatted !== undefined) {
|
||||||
|
declarations.set(declaration.property, formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssObject = normalizeCssDeclarationRecord(values[getSubtitleCssPath(scope)]);
|
||||||
|
for (const [property, value] of Object.entries(cssObject)) {
|
||||||
|
declarations.set(normalizeCssPropertyName(property), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...declarations.entries()]
|
||||||
|
.map(([property, value]) => `${property}: ${value};`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSubtitleCssDeclarations(text: string): SubtitleCssParseResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return { ok: true, declarations: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[{}]/.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Enter CSS declarations only, without selectors or braces.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations: Record<string, string> = {};
|
||||||
|
for (const rawDeclaration of splitCssDeclarations(trimmed)) {
|
||||||
|
const declaration = rawDeclaration.trim();
|
||||||
|
if (declaration.length === 0) continue;
|
||||||
|
|
||||||
|
const colonIndex = findTopLevelColon(declaration);
|
||||||
|
if (colonIndex <= 0) {
|
||||||
|
return { ok: false, error: `Invalid CSS declaration: ${declaration}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const property = normalizeCssPropertyName(declaration.slice(0, colonIndex).trim());
|
||||||
|
const value = declaration.slice(colonIndex + 1).trim();
|
||||||
|
if (!CSS_PROPERTY_PATTERN.test(property)) {
|
||||||
|
return { ok: false, error: `Invalid CSS property: ${property}` };
|
||||||
|
}
|
||||||
|
if (value.length === 0) {
|
||||||
|
return { ok: false, error: `Missing CSS value for ${property}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
declarations[property] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, declarations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCssDeclarationRecord(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations: Record<string, string> = {};
|
||||||
|
for (const [property, rawValue] of Object.entries(value)) {
|
||||||
|
if (typeof rawValue !== 'string') continue;
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed.length === 0) continue;
|
||||||
|
declarations[property] = trimmed;
|
||||||
|
}
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCssPropertyName(property: string): string {
|
||||||
|
const trimmed = property.trim();
|
||||||
|
if (trimmed.startsWith('--')) return trimmed;
|
||||||
|
if (trimmed.includes('-')) return trimmed.toLowerCase();
|
||||||
|
|
||||||
|
const kebab = trimmed
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/^Webkit-/, '-webkit-')
|
||||||
|
.toLowerCase();
|
||||||
|
return kebab.startsWith('webkit-') ? `-${kebab}` : kebab;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCssLengthLikeValue(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return `${value}px`;
|
||||||
|
}
|
||||||
|
return formatCssPrimitiveValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCssPrimitiveValue(value: unknown): string | undefined {
|
||||||
|
if (value === null || value === undefined || typeof value === 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCssDeclarations(text: string): string[] {
|
||||||
|
const declarations: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
let parenDepth = 0;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (const char of text) {
|
||||||
|
if (escaping) {
|
||||||
|
current += char;
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\\') {
|
||||||
|
current += char;
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
current += char;
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
current += char;
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '(') {
|
||||||
|
parenDepth += 1;
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ')') {
|
||||||
|
parenDepth = Math.max(0, parenDepth - 1);
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ';' && parenDepth === 0) {
|
||||||
|
declarations.push(current);
|
||||||
|
current = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
declarations.push(current);
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTopLevelColon(text: string): number {
|
||||||
|
let quote: '"' | "'" | null = null;
|
||||||
|
let parenDepth = 0;
|
||||||
|
let escaping = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
const char = text[i];
|
||||||
|
if (escaping) {
|
||||||
|
escaping = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '\\') {
|
||||||
|
escaping = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '(') {
|
||||||
|
parenDepth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ')') {
|
||||||
|
parenDepth = Math.max(0, parenDepth - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ':' && parenDepth === 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ export interface AnkiConnectConfig {
|
|||||||
color?: string;
|
color?: string;
|
||||||
};
|
};
|
||||||
nPlusOne?: {
|
nPlusOne?: {
|
||||||
|
enabled?: boolean;
|
||||||
nPlusOne?: string;
|
nPlusOne?: string;
|
||||||
minSentenceWords?: number;
|
minSentenceWords?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export interface ResolvedConfig {
|
|||||||
decks: Record<string, string[]>;
|
decks: Record<string, string[]>;
|
||||||
};
|
};
|
||||||
nPlusOne: {
|
nPlusOne: {
|
||||||
|
enabled: boolean;
|
||||||
minSentenceWords: number;
|
minSentenceWords: number;
|
||||||
};
|
};
|
||||||
behavior: {
|
behavior: {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export type ConfigSettingsControl =
|
|||||||
| 'anki-note-type'
|
| 'anki-note-type'
|
||||||
| 'anki-field'
|
| 'anki-field'
|
||||||
| 'mpv-keybindings'
|
| 'mpv-keybindings'
|
||||||
| 'color-list';
|
| 'color-list'
|
||||||
|
| 'css-declarations';
|
||||||
|
|
||||||
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
|
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export interface ConfigSettingsField {
|
|||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
secret?: boolean;
|
secret?: boolean;
|
||||||
legacyHidden?: boolean;
|
legacyHidden?: boolean;
|
||||||
|
settingsHidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigSettingsSnapshotValue = unknown;
|
export type ConfigSettingsSnapshotValue = unknown;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
|
|||||||
|
|
||||||
export interface SubtitleStyleConfig {
|
export interface SubtitleStyleConfig {
|
||||||
primaryDefaultMode?: PrimarySubMode;
|
primaryDefaultMode?: PrimarySubMode;
|
||||||
|
css?: Record<string, string>;
|
||||||
enableJlpt?: boolean;
|
enableJlpt?: boolean;
|
||||||
preserveLineBreaks?: boolean;
|
preserveLineBreaks?: boolean;
|
||||||
autoPauseVideoOnHover?: boolean;
|
autoPauseVideoOnHover?: boolean;
|
||||||
@@ -110,6 +111,7 @@ export interface SubtitleStyleConfig {
|
|||||||
bandedColors?: [string, string, string, string, string];
|
bandedColors?: [string, string, string, string, string];
|
||||||
};
|
};
|
||||||
secondary?: {
|
secondary?: {
|
||||||
|
css?: Record<string, string>;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontColor?: string;
|
fontColor?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user