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:
2026-05-17 04:13:02 -07:00
parent 3447103857
commit 81830b3372
39 changed files with 1147 additions and 86 deletions
+4
View File
@@ -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.
+4
View File
@@ -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`.
+4 -1
View File
@@ -360,6 +360,7 @@
// ==========================================
"subtitleStyle": {
"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
"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
@@ -406,6 +407,7 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"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.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
@@ -523,6 +525,7 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"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).
}, // N plus one setting.
"metadata": {
@@ -530,7 +533,7 @@
}, // Metadata setting.
"isLapis": {
"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.
"isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
+24 -16
View File
@@ -324,25 +324,29 @@ See `config.example.jsonc` for detailed configuration options.
```json
{
"subtitleStyle": {
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
"fontSize": 35,
"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",
"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": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
"fontSize": 24,
"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"`) |
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
| `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"`) |
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
| `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.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
| `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`.
@@ -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.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.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`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
+4 -1
View File
@@ -360,6 +360,7 @@
// ==========================================
"subtitleStyle": {
"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
"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
@@ -406,6 +407,7 @@
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"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.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
@@ -523,6 +525,7 @@
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"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).
}, // N plus one setting.
"metadata": {
@@ -530,7 +533,7 @@
}, // Metadata setting.
"isLapis": {
"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.
"isKiku": {
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
+2 -2
View File
@@ -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: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: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: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: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/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: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",
+2 -1
View File
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
}
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 {
+4 -2
View File
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
}
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
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
};
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
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 (this.started) {
@@ -69,6 +69,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
autoUpdateNewCards: true,
},
nPlusOne: {
enabled: false,
minSentenceWords: 3,
},
metadata: {
@@ -76,7 +77,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
isLapis: {
enabled: false,
sentenceCardModel: 'Japanese sentences',
sentenceCardModel: 'Lapis',
},
isKiku: {
enabled: false,
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
subtitleStyle: {
primaryDefaultMode: 'visible',
css: {},
enableJlpt: false,
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
@@ -43,6 +44,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
},
secondary: {
css: {},
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
fontSize: 24,
fontColor: '#cad3f5',
@@ -278,6 +278,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.addMinedWordsImmediately,
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',
kind: 'number',
@@ -13,6 +13,20 @@ export function buildSubtitleConfigOptionRegistry(
description:
'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',
kind: 'boolean',
+15
View File
@@ -789,6 +789,21 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
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 hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
+46
View File
@@ -10,6 +10,21 @@ import {
isObject,
} 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 {
const { src, resolved, warn } = context;
@@ -159,6 +174,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
const fallbackSubtitleStyleCss = { ...resolved.subtitleStyle.css };
const fallbackSubtitleStyleSecondaryCss = { ...resolved.subtitleStyle.secondary.css };
const fallbackFrequencyDictionary = {
...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(
(src.subtitleStyle as { preserveLineBreaks?: unknown }).preserveLineBreaks,
);
+40
View File
@@ -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', () => {
const { context, warnings } = createResolveContext({
subtitleStyle: {
+1 -1
View File
@@ -188,7 +188,7 @@ export function buildConfigSettingsSnapshot(
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
values[field.configPath] = structuredClone(rawValue != null ? rawValue : resolvedValue);
}
return {
+47 -2
View File
@@ -28,17 +28,60 @@ test('settings registry groups annotation display fields by config group', () =>
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', () => {
assert.equal(field('ankiConnect.knownWords.decks').control, 'known-words-decks');
assert.equal(field('ankiConnect.isLapis.sentenceCardModel').control, 'anki-note-type');
assert.equal(field('ankiConnect.fields.word').control, 'anki-field');
assert.equal(field('keybindings').control, 'mpv-keybindings');
assert.equal(field('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('subtitleSidebar.toggleKey').control, 'key-code');
assert.equal(field('stats.toggleKey').control, 'key-code');
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', () => {
const ankiConnect = fields.filter((candidate) => candidate.section === 'AnkiConnect');
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(
(candidate) => candidate.section === 'Kiku Features And Lapis Features',
(candidate) => candidate.section === 'Kiku/Lapis Features',
);
assert.deepEqual(
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.deviceId',
'jellyfin.clientName',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
+141 -13
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
} from '../../settings/subtitle-style-css';
type Leaf = {
path: string;
@@ -67,6 +71,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'jellyfin.defaultLibraryId',
'jellyfin.deviceId',
'controller.buttonIndices',
'subtitleSidebar.toggleKey',
'jellyfin.recentServers',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices', 'youtubeSubgen'] as const;
@@ -76,11 +82,19 @@ const JSON_OBJECT_FIELDS = new Set([
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
'subtitleStyle.css',
'subtitleStyle.secondary.css',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
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]));
@@ -106,7 +120,7 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Behavior',
'Note Fields',
'Media Capture',
'Kiku Features And Lapis Features',
'Kiku/Lapis Features',
'Anki AI',
'AnkiConnect Proxy',
'AnkiConnect',
@@ -123,20 +137,48 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.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]),
);
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> = {
'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',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.css': 'CSS Declarations',
'subtitleStyle.secondary.css': 'CSS Declarations',
'secondarySub.defaultMode': 'Secondary Subtitle Visibility Mode',
'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.',
'ankiConnect.isLapis.sentenceCardModel':
'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> {
@@ -215,6 +261,28 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
) {
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 (
path.startsWith('ankiConnect.knownWords.') ||
path.startsWith('ankiConnect.nPlusOne.') ||
@@ -243,7 +311,6 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
'subtitleSidebar.autoOpen',
'subtitleSidebar.autoScroll',
'subtitleSidebar.layout',
'subtitleSidebar.toggleKey',
]);
return sidebarBehaviorPaths.has(path)
? { category: 'behavior', section: 'Subtitle Sidebar Behavior' }
@@ -259,7 +326,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
return { category: 'mining-anki', section: 'Media Capture' };
}
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.')) {
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) };
}
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
return { category: 'input', section: 'Overlay Shortcuts' };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay Shortcuts' };
}
@@ -346,6 +416,7 @@ function topSection(path: string): string {
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (getSubtitleCssScopeForPath(path)) return 'css-declarations';
if (path === 'keybindings') return 'mpv-keybindings';
if (path === 'ankiConnect.knownWords.decks') return 'known-words-decks';
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 {
if (path.startsWith('ankiConnect.knownWords.')) return 'Known Words';
if (path.startsWith('ankiConnect.nPlusOne.')) return 'N+1';
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
if (path === 'subtitleStyle.enableJlpt' || path.startsWith('subtitleStyle.jlptColors.')) {
return 'JLPT';
}
if (path.startsWith('subtitleStyle.frequencyDictionary.')) return 'Frequency Dictionary';
if (
path === 'subtitleStyle.frequencyDictionary.enabled' ||
path === 'subtitleStyle.frequencyDictionary.singleColor' ||
path === 'subtitleStyle.frequencyDictionary.bandedColors'
) {
return 'Frequency Highlighting';
}
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
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;
}
@@ -418,9 +543,9 @@ function compareFields(a: ConfigSettingsField, b: ConfigSettingsField): number {
const sectionName = a.section.localeCompare(b.section);
if (sectionName !== 0) return sectionName;
const subsection =
(SUBSECTION_ORDER.get(a.subsection ?? '') ?? Number.MAX_SAFE_INTEGER) -
(SUBSECTION_ORDER.get(b.subsection ?? '') ?? Number.MAX_SAFE_INTEGER);
const aSubOrder = a.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(a.subsection) ?? Number.MAX_SAFE_INTEGER);
const bSubOrder = b.subsection === undefined ? -1 : (SUBSECTION_ORDER.get(b.subsection) ?? Number.MAX_SAFE_INTEGER);
const subsection = aSubOrder - bSubOrder;
if (subsection !== 0) return subsection;
const subsectionName = (a.subsection ?? '').localeCompare(b.subsection ?? '');
@@ -446,7 +571,9 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
pathStartsWith(path, 'ankiConnect.ai') ||
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey'
) {
return 'hot-reload';
}
@@ -474,6 +601,7 @@ function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
settingsHidden: SUBTITLE_CSS_MANAGED_CONFIG_PATHS.has(leaf.path),
};
}
+2 -2
View File
@@ -2576,7 +2576,7 @@ function shouldInitializeMecabForAnnotations(): boolean {
const config = getResolvedConfig();
const nPlusOneEnabled = getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
config.ankiConnect.knownWords.highlightEnabled,
config.ankiConnect.nPlusOne.enabled,
);
const jlptEnabled = getRuntimeBooleanOption(
'subtitle.annotation.jlpt',
@@ -4187,7 +4187,7 @@ const {
getNPlusOneEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
getResolvedConfig().ankiConnect.nPlusOne.enabled,
),
getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
+49
View File
@@ -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', () => {
const restoreDocument = installFakeDocument();
try {
+45 -5
View File
@@ -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(
declarations: Record<string, unknown>,
includedKeys: ReadonlySet<string>,
@@ -172,7 +198,9 @@ function pickInlineStyleDeclarations(
const CONTAINER_STYLE_KEYS = new Set<string>([
'background',
'background-color',
'backgroundColor',
'backdrop-filter',
'backdropFilter',
'WebkitBackdropFilter',
'webkitBackdropFilter',
@@ -180,7 +208,7 @@ const CONTAINER_STYLE_KEYS = new Set<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];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
@@ -193,6 +221,7 @@ function resolveSecondaryBackgroundColor(declarations: Record<string, unknown>):
function resolveSecondaryBackdropFilter(declarations: Record<string, unknown>): string {
for (const key of [
'backdropFilter',
'backdrop-filter',
'WebkitBackdropFilter',
'webkitBackdropFilter',
'-webkit-backdrop-filter',
@@ -762,20 +791,26 @@ export function createSubtitleRenderer(ctx: RendererContext) {
'--subtitle-frequency-band-5-color',
frequencyBandedColors[4],
);
applySubtitleCssDeclarations(
ctx.dom.subtitleRoot,
ctx.dom.subtitleContainer,
normalizeCssDeclarationObject(style.css),
);
const secondaryStyle = style.secondary;
if (!secondaryStyle) return;
const secondaryStyleDeclarations = secondaryStyle as Record<string, unknown>;
const secondaryCssDeclarations = normalizeCssDeclarationObject(secondaryStyle.css);
applyInlineStyleDeclarations(
ctx.dom.secondarySubRoot,
secondaryStyleDeclarations,
CONTAINER_STYLE_KEYS,
);
const secondaryContainerStyleDeclarations = pickInlineStyleDeclarations(
secondaryStyleDeclarations,
CONTAINER_STYLE_KEYS,
);
const secondaryContainerStyleDeclarations = {
...pickInlineStyleDeclarations(secondaryStyleDeclarations, CONTAINER_STYLE_KEYS),
...pickInlineStyleDeclarations(secondaryCssDeclarations, CONTAINER_STYLE_KEYS),
};
ctx.dom.secondarySubContainer.style.setProperty(
'--secondary-sub-background-color',
resolveSecondaryBackgroundColor(secondaryContainerStyleDeclarations),
@@ -800,6 +835,11 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (secondaryStyle.fontStyle) {
ctx.dom.secondarySubRoot.style.fontStyle = secondaryStyle.fontStyle;
}
applySubtitleCssDeclarations(
ctx.dom.secondarySubRoot,
ctx.dom.secondarySubContainer,
secondaryCssDeclarations,
);
}
return {
-1
View File
@@ -33,7 +33,6 @@
placeholder="Search"
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>
</div>
</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'),
'',
);
});
+30 -3
View File
@@ -17,6 +17,7 @@ const state: {
modelFieldNamesErrors: Map<string, string>;
noteFieldModelName: string;
ankiConnectUrl: string;
noteFieldModelNameManuallySelected: boolean;
} = {
deckNames: null,
deckNamesLoading: false,
@@ -32,6 +33,7 @@ const state: {
modelFieldNamesErrors: new Map(),
noteFieldModelName: '',
ankiConnectUrl: '',
noteFieldModelNameManuallySelected: false,
};
let requestRender = (): void => undefined;
@@ -42,11 +44,32 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') {
if (
!state.noteFieldModelName &&
!state.noteFieldModelNameManuallySelected &&
typeof configuredNoteType === 'string'
) {
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[] {
return Array.isArray(value)
? 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) {
state.modelNames = uniqueSorted(result.values);
state.modelNamesError = null;
if (!state.noteFieldModelName && state.modelNames[0]) {
state.noteFieldModelName = state.modelNames[0];
if (!state.noteFieldModelNameManuallySelected) {
state.noteFieldModelName = selectPreferredNoteFieldModelName(
state.modelNames,
state.noteFieldModelName,
);
}
} else {
state.modelNames = [];
@@ -318,6 +344,7 @@ export function renderNoteFieldModelPicker(context: SettingsControlContext): HTM
select.value = state.noteFieldModelName;
select.addEventListener('change', () => {
state.noteFieldModelName = select.value;
state.noteFieldModelNameManuallySelected = true;
requestRender();
});
control.append(select);
+1
View File
@@ -2,6 +2,7 @@ import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/
export interface SettingsControlContext {
setFieldError(path: string, message: string | null): void;
resetDraftPath(path: string, defaultValue?: ConfigSettingsSnapshotValue): void;
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
+61 -2
View File
@@ -10,12 +10,23 @@ import {
} from './settings-anki-controls';
import type { SettingsControlContext } from './settings-control-context';
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
import { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls';
import {
configureKeybindingControls,
renderKeyboardInput,
renderMpvKeybindingsInput,
} from './settings-keybinding-controls';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
parseSubtitleCssDeclarations,
serializeSubtitleCssDeclarations,
} from './subtitle-style-css';
export { renderNoteFieldModelPicker };
export function configureSettingsControls(options: { requestRender: () => void }): void {
configureAnkiControls(options);
configureKeybindingControls(options);
}
export function initializeSettingsControls(
@@ -90,6 +101,44 @@ function renderStringListInput(
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(
field: ConfigSettingsField,
context: SettingsControlContext,
@@ -134,7 +183,13 @@ export function renderControl(
if (field.control === 'number') {
const input = createElement('input', 'config-input') as HTMLInputElement;
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', () => {
const next = parseOptionalNumberInputValue(input.value);
if (next.ok) {
@@ -174,6 +229,10 @@ export function renderControl(
return renderJsonInput(context, field, value);
}
if (field.control === 'css-declarations') {
return renderCssDeclarationsInput(context, field);
}
if (field.control === 'textarea') {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false;
+26 -2
View File
@@ -12,6 +12,11 @@ import type { SettingsControlContext } from './settings-control-context';
import { createElement } from './settings-control-dom';
let activeKeyLearningStop: (() => void) | null = null;
let requestRender = (): void => undefined;
export function configureKeybindingControls(options: { requestRender: () => void }): void {
requestRender = options.requestRender;
}
function startKeyLearning(
button: HTMLButtonElement,
@@ -107,7 +112,8 @@ export function renderMpvKeybindingsInput(
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
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 keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
row.key = next;
@@ -130,9 +136,27 @@ export function renderMpvKeybindingsInput(
row.commandText = command.value;
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);
}
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;
}
+11
View File
@@ -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);
});
+45
View File
@@ -4,6 +4,7 @@ import {
createSettingsDraft,
filterSettingsFields,
setDraftValue,
resetDraftPath,
getDirtyOperations,
} from './settings-model';
import type { ConfigSettingsField } from '../types/settings';
@@ -31,6 +32,18 @@ const fields: ConfigSettingsField[] = [
defaultValue: true,
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', () => {
@@ -41,6 +54,16 @@ test('filterSettingsFields searches label, section, and config path', () => {
['subtitleStyle.autoPauseVideoOnHover'],
);
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', () => {
@@ -60,3 +83,25 @@ test('settings draft tracks dirty set and emits save operations', () => {
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
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',
},
]);
});
+18 -7
View File
@@ -6,7 +6,7 @@ import type {
} from '../types/settings';
export interface SettingsFilter {
category: ConfigSettingsCategory;
category?: ConfigSettingsCategory;
query?: string;
}
@@ -20,6 +20,15 @@ function normalizeQuery(query: string | undefined): string {
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 {
return JSON.stringify(a) === JSON.stringify(b);
}
@@ -29,24 +38,26 @@ export function filterSettingsFields(
filter: SettingsFilter,
): ConfigSettingsField[] {
const query = normalizeQuery(filter.query);
const terms = query.length > 0 ? query.split(/\s+/) : [];
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;
}
if (!query) {
return true;
}
const haystack = [
const haystack = searchableText([
field.label,
field.description,
field.configPath,
field.section,
field.subsection ?? '',
field.enumValues?.join(' ') ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(query);
]);
return terms.every((term) => haystack.includes(term));
});
}
+55 -19
View File
@@ -20,6 +20,7 @@ import {
setDraftValue,
type SettingsDraft,
} from './settings-model';
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
declare global {
interface Window {
@@ -76,7 +77,6 @@ const dom = {
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
categoryMeta: getElement<HTMLElement>('categoryMeta'),
searchInput: getElement<HTMLInputElement>('searchInput'),
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
saveButton: getElement<HTMLButtonElement>('saveButton'),
statusBanner: getElement<HTMLElement>('statusBanner'),
warningsPanel: getElement<HTMLElement>('warningsPanel'),
@@ -163,6 +163,13 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
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 {
dom.warningsPanel.replaceChildren();
if (snapshot.warnings.length === 0) {
@@ -192,7 +199,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
dom.categoryNav.replaceChildren();
for (const category of CATEGORY_ORDER) {
const count = snapshot.fields.filter(
(field) => field.category === category && !field.legacyHidden,
(field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
).length;
if (count === 0) continue;
const button = createElement('button', 'category-button') as HTMLButtonElement;
@@ -206,6 +213,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
button.addEventListener('click', () => {
state.category = category;
render();
dom.settingsContent.scrollTop = 0;
});
dom.categoryNav.append(button);
}
@@ -222,7 +230,13 @@ function renderField(field: ConfigSettingsField): HTMLElement {
const controlWrap = createElement('div', 'field-control');
controlWrap.append(
renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath }),
renderControl(field, {
setFieldError,
resetDraftPath: resetDraftPathContext,
updateDraft,
valueForField,
valueForPath,
}),
);
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
resetButton.type = 'button';
@@ -230,6 +244,12 @@ function renderField(field: ConfigSettingsField): HTMLElement {
resetButton.addEventListener('click', () => {
if (!state.draft) return;
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);
render();
});
@@ -240,13 +260,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
dom.settingsContent.replaceChildren();
const query = state.query.trim();
const fields = filterSettingsFields(snapshot.fields, {
category: state.category,
query: state.query,
category: query ? undefined : state.category,
query,
});
if (query) {
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) {
const empty = createElement('div', 'empty-state');
@@ -255,25 +286,35 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
return;
}
const sections = new Map<string, ConfigSettingsField[]>();
const sections = new Map<
string,
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
>();
for (const field of fields) {
const sectionFields = sections.get(field.section) ?? [];
sectionFields.push(field);
sections.set(field.section, sectionFields);
const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
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 title = createElement('h2');
title.textContent = section;
title.textContent = section.title;
sectionEl.append(title);
if (section === 'Note Fields') {
if (section.rawSection === 'Note Fields') {
sectionEl.append(
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }),
renderNoteFieldModelPicker({
setFieldError,
resetDraftPath: resetDraftPathContext,
updateDraft,
valueForField,
valueForPath,
}),
);
}
let currentSubsection = '';
for (const field of sectionFields) {
for (const field of section.fields) {
if (field.subsection && field.subsection !== currentSubsection) {
currentSubsection = field.subsection;
const subsectionTitle = createElement('h3', 'settings-subsection-title');
@@ -353,11 +394,6 @@ dom.searchInput.addEventListener('input', () => {
dom.saveButton.addEventListener('click', () => {
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) => {
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
+8 -2
View File
@@ -262,7 +262,7 @@ h1 {
}
.search-input {
width: 210px;
width: min(360px, 34vw);
height: 36px;
padding: 0 12px;
}
@@ -296,6 +296,12 @@ h1 {
min-height: 86px;
}
.config-textarea.css-declarations {
width: min(560px, 100%);
min-height: 188px;
tab-size: 2;
}
.search-input:hover,
.config-input:hover,
.config-textarea:hover {
@@ -675,7 +681,7 @@ code {
.keybinding-row {
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;
align-items: start;
}
+88
View File
@@ -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,
);
});
+302
View File
@@ -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;
}
+1
View File
@@ -86,6 +86,7 @@ export interface AnkiConnectConfig {
color?: string;
};
nPlusOne?: {
enabled?: boolean;
nPlusOne?: string;
minSentenceWords?: number;
};
+1
View File
@@ -214,6 +214,7 @@ export interface ResolvedConfig {
decks: Record<string, string[]>;
};
nPlusOne: {
enabled: boolean;
minSentenceWords: number;
};
behavior: {
+3 -1
View File
@@ -26,7 +26,8 @@ export type ConfigSettingsControl =
| 'anki-note-type'
| 'anki-field'
| 'mpv-keybindings'
| 'color-list';
| 'color-list'
| 'css-declarations';
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
@@ -45,6 +46,7 @@ export interface ConfigSettingsField {
advanced?: boolean;
secret?: boolean;
legacyHidden?: boolean;
settingsHidden?: boolean;
}
export type ConfigSettingsSnapshotValue = unknown;
+2
View File
@@ -70,6 +70,7 @@ export type FrequencyDictionaryMatchMode = 'headword' | 'surface';
export interface SubtitleStyleConfig {
primaryDefaultMode?: PrimarySubMode;
css?: Record<string, string>;
enableJlpt?: boolean;
preserveLineBreaks?: boolean;
autoPauseVideoOnHover?: boolean;
@@ -110,6 +111,7 @@ export interface SubtitleStyleConfig {
bandedColors?: [string, string, string, string, string];
};
secondary?: {
css?: Record<string, string>;
fontFamily?: string;
fontSize?: number;
fontColor?: string;