migrate subtitle style config to CSS declaration shape

- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects
- Hover token colors migrated to --subtitle-hover-token-color CSS custom properties
- Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped
- Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis
- New migration for legacy ankiConnect N+1 config paths
This commit is contained in:
2026-05-18 03:01:31 -07:00
parent 1bb7b26641
commit 193b3136f2
33 changed files with 990 additions and 339 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Updated the generated example config to use the same CSS declaration paths written by the Settings window for subtitle and sidebar appearance.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: config
- Defaulted the note-fields note type picker to exact `Kiku` when available, then exact `Lapis`, otherwise leaving it blank for manual selection.
- Defaulted the note-fields note type picker to the configured Anki deck's note type when available, then exact `Kiku`, then exact `Lapis`, otherwise leaving it blank for manual selection.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind.
+45 -46
View File
@@ -11,7 +11,7 @@
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
// SubMiner can still auto-start in the background when this is false.
// ==========================================
"auto_start_overlay": false, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
// ==========================================
// Texthooker Server
@@ -361,30 +361,29 @@
// ==========================================
"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.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "35px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
}, // 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
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"paintOrder": "", // Paint order setting.
"WebkitTextStroke": "", // Webkit text stroke setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"jlptColors": {
@@ -410,22 +409,21 @@
] // 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": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"paintOrder": "", // Paint order setting.
"WebkitTextStroke": "", // Webkit text stroke setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "600", // Font weight setting.
"fontStyle": "normal" // Font style setting.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "24px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
@@ -441,17 +439,18 @@
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": {}, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
"font-size": "16px", // Font size setting.
"opacity": "0.95", // Opacity setting.
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
+39 -34
View File
@@ -8,10 +8,6 @@ outline: [2, 3]
import { withBase } from 'vitepress';
</script>
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
## Quick Start
For most users, start with this minimal configuration:
@@ -39,9 +35,38 @@ For most users, start with this minimal configuration:
Then customize as needed using the sections below.
## Settings
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
- Appearance
- Behavior
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
## Configuration File
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
Generate a fresh default config from the centralized config registry:
@@ -63,29 +88,6 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
### Configuration Window
SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape:
- Appearance
- Behavior
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, YouTube subtitle-generation settings, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, Jellyfin client identity/library defaults, and controller binding/profile internals that are edited in-app. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
@@ -357,7 +359,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 |
| `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"`) |
@@ -369,8 +371,8 @@ See `config.example.jsonc` for detailed configuration options.
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
@@ -381,7 +383,10 @@ See `config.example.jsonc` for detailed configuration options.
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
| `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.
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
`textShadow` remain supported for hand-written or older 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`.
@@ -974,7 +979,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.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`) |
@@ -1262,7 +1267,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The configuration window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
+45 -46
View File
@@ -11,7 +11,7 @@
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
// SubMiner can still auto-start in the background when this is false.
// ==========================================
"auto_start_overlay": false, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
// ==========================================
// Texthooker Server
@@ -361,30 +361,29 @@
// ==========================================
"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.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "35px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
}, // 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
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"fontWeight": "600", // Font weight setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"paintOrder": "", // Paint order setting.
"WebkitTextStroke": "", // Webkit text stroke setting.
"fontStyle": "normal", // Font style setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"jlptColors": {
@@ -410,22 +409,21 @@
] // 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": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 24, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
"lineHeight": 1.35, // Line height setting.
"letterSpacing": "-0.01em", // Letter spacing setting.
"wordSpacing": 0, // Word spacing setting.
"fontKerning": "normal", // Font kerning setting.
"textRendering": "geometricPrecision", // Text rendering setting.
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"paintOrder": "", // Paint order setting.
"WebkitTextStroke": "", // Webkit text stroke setting.
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "600", // Font weight setting.
"fontStyle": "normal" // Font style setting.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "transparent", // Background color setting.
"font-size": "24px", // Font size setting.
"font-weight": "600", // Font weight setting.
"font-style": "normal", // Font style setting.
"line-height": "1.35", // Line height setting.
"letter-spacing": "-0.01em", // Letter spacing setting.
"word-spacing": "0", // Word spacing setting.
"font-kerning": "normal", // Font kerning setting.
"text-rendering": "geometricPrecision", // Text rendering setting.
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
@@ -441,17 +439,18 @@
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
"css": {}, // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
"opacity": 0.95, // Base opacity applied to the sidebar shell.
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family used for subtitle sidebar cue text.
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
"css": {
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"color": "#cad3f5", // Color setting.
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
"font-size": "16px", // Font size setting.
"opacity": "0.95", // Opacity setting.
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
+5 -2
View File
@@ -321,8 +321,11 @@ function M.create(ctx)
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
attempt = attempt or 1
run_control_command_async("app-ping", nil, function(ok)
if ok == expected_running then
run_control_command_async("app-ping", nil, function(_ok, result)
local status = result and result.status
local is_running = status == 0
local is_not_running = status == 1
if (expected_running and is_running) or ((not expected_running) and is_not_running) then
on_ready()
return
end
+35
View File
@@ -735,6 +735,41 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
app_ping_statuses = { 0, 2, 1, 0 },
option_overrides = {
binary_path = binary_path,
auto_start = "no",
auto_start_visible_overlay = "no",
},
files = {
[binary_path] = true,
},
})
assert_true(
recorded ~= nil,
"plugin failed to load for transient app-ping failure restart scenario: " .. tostring(err)
)
recorded.script_messages["subminer-restart"]()
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "manual restart should start after app-ping reports stopped")
local start_index = find_call_index(recorded.async_calls, start_call) or 0
local failed_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
local stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
assert_true(failed_ping ~= nil, "manual restart should retry after transient app-ping failure")
assert_true(stopped_ping ~= nil, "manual restart should observe stopped app-ping status")
assert_true(
(find_call_index(recorded.async_calls, failed_ping) or 0) < start_index,
"manual restart should not treat app-ping status 2 as stopped"
)
assert_true(
(find_call_index(recorded.async_calls, stopped_ping) or 0) < start_index,
"manual restart should wait for explicit stopped app-ping status"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -0,0 +1,194 @@
import {
getNodeValue,
parseTree as parseJsoncTree,
type Node as JsoncNode,
type ParseError,
} from 'jsonc-parser';
import type { RawConfig } from '../types/config';
import type { ConfigSettingsPatchOperation } from '../types/settings';
import { DEFAULT_CONFIG } from './definitions';
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
export type LegacyAnkiConnectNPlusOneMigrationResult =
| {
migrated: true;
content: string;
rawConfig: RawConfig;
}
| {
migrated: false;
content: string;
rawConfig: RawConfig;
};
const LEGACY_N_PLUS_ONE_PATH_MAP = {
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
matchMode: 'ankiConnect.knownWords.matchMode',
decks: 'ankiConnect.knownWords.decks',
knownWord: 'subtitleStyle.knownWordColor',
nPlusOne: 'subtitleStyle.nPlusOneColor',
} as const;
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
return propertyNode?.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
return matches.at(-1);
}
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
return objectProperties(node).filter((property) => propertyKey(property) === key);
}
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
let node = root;
for (const segment of path.split('.')) {
node = propertyValue(findLastProperty(node, segment));
if (!node) return undefined;
}
return node;
}
function hasPath(root: JsoncNode | undefined, path: string): boolean {
return findValueAtPath(root, path) !== undefined;
}
function normalizeLegacyDecks(value: unknown): unknown {
if (!Array.isArray(value)) {
return value;
}
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
const decks = value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
const normalized: Record<string, string[]> = {};
for (const deck of new Set(decks)) {
normalized[deck] = defaultFields;
}
return normalized;
}
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
operations: ConfigSettingsPatchOperation[];
hasLegacy: boolean;
} {
const operations: ConfigSettingsPatchOperation[] = [];
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
if (nPlusOneObjects.length === 0) {
return { operations, hasLegacy: false };
}
const canonicalNPlusOneValues = new Map<string, unknown>();
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
let hasLegacy = false;
for (const nPlusOne of nPlusOneObjects) {
for (const property of objectProperties(nPlusOne)) {
const key = propertyKey(property);
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
hasLegacy = true;
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
}
}
}
if (nPlusOneObjects.length > 1) {
for (const [key, value] of canonicalNPlusOneValues) {
operations.push({
op: 'set',
path: `ankiConnect.nPlusOne.${key}`,
value,
});
}
}
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
>) {
if (!legacyValues.has(key)) continue;
if (!hasPath(root, path)) {
const value =
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
operations.push({
op: 'set',
path,
value,
});
}
operations.push({
op: 'reset',
path: `ankiConnect.nPlusOne.${key}`,
});
}
return { operations, hasLegacy };
}
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
content: string;
rawConfig: RawConfig;
}): LegacyAnkiConnectNPlusOneMigrationResult {
const errors: ParseError[] = [];
const root = parseJsoncTree(options.content || '{}', errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!root || errors.length > 0) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
if (operations.length === 0 && !hasLegacy) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
const result = applyConfigSettingsPatchToContent({
content: options.content,
operations,
previousWarnings: [],
});
if (!result.ok) {
return {
migrated: false,
content: options.content,
rawConfig: options.rawConfig,
};
}
return {
migrated: true,
content: result.content,
rawConfig: result.rawConfig,
};
}
+131 -13
View File
@@ -12,16 +12,44 @@ import {
} from './definitions';
import { parseConfigContent } from './parse';
import { generateConfigTemplate } from './template';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const DEFAULT_SUBTITLE_FONT_FAMILY =
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
const values: Record<string, unknown> = {
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
};
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
}
return buildSubtitleCssDeclarationObject(scope, values);
}
test('loads defaults when config is missing', () => {
const dir = makeTempDir();
const service = new ConfigService(dir);
@@ -203,6 +231,8 @@ test('migrates legacy subtitle appearance options into css declaration objects o
"subtitleStyle": {
"fontSize": 42,
"fontColor": "#ffffff",
"hoverTokenColor": "#abcdef",
"hoverTokenBackgroundColor": "transparent",
"css": {
"font-size": "44px",
"text-wrap": "balance"
@@ -230,6 +260,8 @@ test('migrates legacy subtitle appearance options into css declaration objects o
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
hoverTokenColor?: unknown;
hoverTokenBackgroundColor?: unknown;
css?: Record<string, string>;
secondary?: {
fontSize?: unknown;
@@ -249,10 +281,14 @@ test('migrates legacy subtitle appearance options into css declaration objects o
assert.deepEqual(parsed.subtitleStyle.css, {
color: '#ffffff',
'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance',
});
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
color: '#bbbbbb',
'font-size': '28px',
@@ -2004,7 +2040,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
});
test('validates legacy ankiConnect knownWords and n+1 color values', () => {
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
@@ -2027,14 +2063,15 @@ test('validates legacy ankiConnect knownWords and n+1 color values', () => {
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
});
test('maps legacy ankiConnect knownWords and n+1 color values to subtitleStyle', () => {
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
configPath,
`{
"ankiConnect": {
"nPlusOne": {
@@ -2053,12 +2090,21 @@ test('maps legacy ankiConnect knownWords and n+1 color values to subtitleStyle',
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
});
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
configPath,
`{
"ankiConnect": {
"nPlusOne": {
@@ -2076,6 +2122,13 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: {
knownWords: Record<string, unknown>;
nPlusOne?: Record<string, unknown>;
};
subtitleStyle: { knownWordColor?: string };
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
@@ -2085,16 +2138,53 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
warnings.some(
(warning) =>
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
warning.path === 'ankiConnect.nPlusOne.decks' ||
warning.path === 'ankiConnect.nPlusOne.knownWord',
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
),
);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
});
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
"minSentenceWords": 3
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": "3"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne: Record<string, unknown> };
};
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
@@ -2543,6 +2633,34 @@ test('template generator includes known keys', () => {
);
});
test('template generator uses settings CSS declaration paths for appearance fields', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.css'),
buildDefaultSubtitleCssDeclarations('primary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
buildDefaultSubtitleCssDeclarations('secondary'),
);
assert.deepEqual(
getValueAtPath(parsed, 'subtitleSidebar.css'),
buildDefaultSubtitleCssDeclarations('sidebar'),
);
for (const scope of SUBTITLE_CSS_SCOPES) {
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
assert.equal(
getValueAtPath(parsed, path),
undefined,
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
);
}
}
});
test('template generator shows built-in default keybindings in the keybindings array', () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
const parsed = parseConfigContent('config.example.jsonc', output) as {
+1 -1
View File
@@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
auto_start_overlay: true,
};
-122
View File
@@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
const legacyNPlusOneHighlightEnabled = asBoolean(nPlusOneConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
@@ -666,23 +665,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else if (legacyNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = legacyNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (nPlusOneConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.highlightEnabled',
nPlusOneConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
@@ -701,15 +683,10 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const legacyNPlusOneRefreshMinutes = asNumber(nPlusOneConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
const hasValidLegacyNPlusOneRefreshMinutes =
legacyNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyNPlusOneRefreshMinutes) &&
legacyNPlusOneRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
@@ -723,25 +700,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (legacyNPlusOneRefreshMinutes !== undefined) {
if (hasValidLegacyNPlusOneRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.nPlusOne.refreshMinutes',
nPlusOneConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
@@ -828,12 +786,9 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyNPlusOneMatchMode = asString(nPlusOneConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyNPlusOneMatchMode =
legacyNPlusOneMatchMode === 'headword' || legacyNPlusOneMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
@@ -847,25 +802,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyNPlusOneMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyNPlusOneMatchMode;
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.nPlusOne.matchMode',
nPlusOneConfig.matchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
@@ -897,7 +833,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
const legacyNPlusOneDecks = nPlusOneConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
@@ -941,54 +876,14 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
} else if (Array.isArray(legacyNPlusOneDecks)) {
const normalized = legacyNPlusOneDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.nPlusOne.decks',
legacyNPlusOneDecks,
DEFAULT_CONFIG.ankiConnect.knownWords.decks,
'Legacy key is deprecated; use ankiConnect.knownWords.decks with object format',
);
}
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
? (context.src.subtitleStyle as Record<string, unknown>)
: {};
const hasCanonicalNPlusOneColor = rawSubtitleStyle.nPlusOneColor !== undefined;
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const nPlusOneHighlightColor = asColor(nPlusOneConfig.nPlusOne);
if (nPlusOneHighlightColor !== undefined) {
if (!hasCanonicalNPlusOneColor) {
context.resolved.subtitleStyle.nPlusOneColor = nPlusOneHighlightColor;
}
context.warn(
'ankiConnect.nPlusOne.nPlusOne',
nPlusOneConfig.nPlusOne,
context.resolved.subtitleStyle.nPlusOneColor,
'Legacy key is deprecated; use subtitleStyle.nPlusOneColor',
);
} else if (nPlusOneConfig.nPlusOne !== undefined) {
context.warn(
'ankiConnect.nPlusOne.nPlusOne',
nPlusOneConfig.nPlusOne,
context.resolved.subtitleStyle.nPlusOneColor,
'Expected a hex color value.',
);
}
const knownWordsColor = asColor(knownWordsConfig.color);
const legacyNPlusOneKnownWordColor = asColor(nPlusOneConfig.knownWord);
if (knownWordsColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
@@ -1006,23 +901,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
} else if (legacyNPlusOneKnownWordColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = legacyNPlusOneKnownWordColor;
}
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (nPlusOneConfig.knownWord !== undefined) {
context.warn(
'ankiConnect.nPlusOne.knownWord',
nPlusOneConfig.knownWord,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
}
if (
+19
View File
@@ -25,6 +25,23 @@ function asCssDeclarations(value: unknown): Record<string, string> | undefined {
return declarations;
}
const SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY = '--subtitle-hover-token-color';
const SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY = '--subtitle-hover-token-background-color';
function applySubtitleHoverTokenCssCompatibility(
subtitleStyle: ResolvedConfig['subtitleStyle'],
): void {
const hoverTokenColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_COLOR_CSS_PROPERTY];
if (hoverTokenColor !== undefined) {
subtitleStyle.hoverTokenColor = hoverTokenColor;
}
const hoverTokenBackgroundColor = subtitleStyle.css[SUBTITLE_HOVER_TOKEN_BACKGROUND_CSS_PROPERTY];
if (hoverTokenBackgroundColor !== undefined) {
subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
}
}
export function applySubtitleDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -349,6 +366,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
);
}
applySubtitleHoverTokenCssCompatibility(resolved.subtitleStyle);
const nameMatchColor = asColor(
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
);
@@ -34,6 +34,8 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
css: {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
},
secondary: {
css: {
@@ -46,7 +48,11 @@ test('subtitleStyle css declarations accept string declaration maps and warn on
assert.deepEqual(valid.context.resolved.subtitleStyle.css, {
'font-size': '42px',
'text-wrap': 'balance',
'--subtitle-hover-token-color': '#c6a0f6',
'--subtitle-hover-token-background-color': 'transparent',
});
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenColor, '#c6a0f6');
assert.equal(valid.context.resolved.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.deepEqual(valid.context.resolved.subtitleStyle.secondary.css, {
'text-transform': 'uppercase',
});
+27 -16
View File
@@ -4,6 +4,7 @@ import { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types/con
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
import { ConfigPaths, loadRawConfig, loadRawConfigStrict } from './load';
import { resolveConfig } from './resolve';
import { applyLegacyAnkiConnectNPlusOneMigrationToContent } from './anki-connect-nplusone-migration';
import { applyLegacySubtitleStyleCssMigrationToContent } from './subtitle-style-css-migration';
export type ReloadConfigStrictResult =
@@ -51,7 +52,7 @@ export class ConfigService {
throw new ConfigStartupParseError(loadResult.path, loadResult.error);
}
this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(loadResult.config, loadResult.path),
this.migrateLegacyConfig(loadResult.config, loadResult.path),
loadResult.path,
);
}
@@ -74,10 +75,7 @@ export class ConfigService {
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = loadRawConfig(this.configPaths);
return this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
configPath,
);
return this.applyResolvedConfig(this.migrateLegacyConfig(config, configPath), configPath);
}
reloadConfigStrict(): ReloadConfigStrictResult {
@@ -88,7 +86,7 @@ export class ConfigService {
const { config, path: configPath } = loadResult;
const resolvedConfig = this.applyResolvedConfig(
this.migrateLegacySubtitleStyleCssConfig(config, configPath),
this.migrateLegacyConfig(config, configPath),
configPath,
);
return {
@@ -124,22 +122,35 @@ export class ConfigService {
return this.getConfig();
}
private migrateLegacySubtitleStyleCssConfig(config: RawConfig, configPath: string): RawConfig {
private migrateLegacyConfig(config: RawConfig, configPath: string): RawConfig {
if (!fs.existsSync(configPath)) {
return config;
}
try {
const content = fs.readFileSync(configPath, 'utf-8');
const migration = applyLegacySubtitleStyleCssMigrationToContent({
content,
rawConfig: config,
});
if (!migration.migrated) {
return config;
let content = fs.readFileSync(configPath, 'utf-8');
let rawConfig = config;
let migrated = false;
for (const applyMigration of [
applyLegacyAnkiConnectNPlusOneMigrationToContent,
applyLegacySubtitleStyleCssMigrationToContent,
]) {
const migration = applyMigration({
content,
rawConfig,
});
if (!migration.migrated) {
continue;
}
content = migration.content;
rawConfig = migration.rawConfig;
migrated = true;
}
fs.writeFileSync(configPath, migration.content, 'utf-8');
return migration.rawConfig;
if (!migrated) {
return rawConfig;
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch {
return config;
}
+33
View File
@@ -32,6 +32,39 @@ test('applyConfigSettingsPatchToContent preserves JSONC comments while setting n
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent updates effective duplicate object path', () => {
const input = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true
},
"knownWords": {
"highlightEnabled": true
},
"nPlusOne": {
"minSentenceWords": 3
}
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'ankiConnect.nPlusOne.enabled',
value: true,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, 3);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
+107
View File
@@ -2,6 +2,9 @@ import {
applyEdits,
modify,
parse as parseJsonc,
parseTree as parseJsoncTree,
type Edit,
type Node as JsoncNode,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
@@ -91,6 +94,7 @@ function normalizeContent(content: string): string {
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
content = removeDuplicatePropertiesAlongPath(content, operation.path);
const edits = modify(
content,
pathToSegments(operation.path),
@@ -103,6 +107,109 @@ function applySingleOperation(content: string, operation: ConfigSettingsPatchOpe
return applyEdits(content, edits);
}
function propertyKey(propertyNode: JsoncNode): string | undefined {
return propertyNode.children?.[0]?.value;
}
function propertyValue(propertyNode: JsoncNode): JsoncNode | undefined {
return propertyNode.children?.[1];
}
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
return node?.type === 'object' ? (node.children ?? []) : [];
}
function isWhitespace(value: string | undefined): boolean {
return value === ' ' || value === '\t' || value === '\r' || value === '\n';
}
function nextNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index < content.length && isWhitespace(content[index])) {
index += 1;
}
return index;
}
function previousNonWhitespaceOffset(content: string, offset: number): number {
let index = offset;
while (index >= 0 && isWhitespace(content[index])) {
index -= 1;
}
return index;
}
function lineStartOffset(content: string, offset: number): number {
return content.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
}
function removalEditForProperty(content: string, propertyNode: JsoncNode): Edit {
let offset = propertyNode.offset;
let end = propertyNode.offset + propertyNode.length;
const next = nextNonWhitespaceOffset(content, end);
if (content[next] === ',') {
end = next + 1;
const lineStart = lineStartOffset(content, offset);
if (/^[ \t]*$/.test(content.slice(lineStart, offset))) {
offset = lineStart;
}
} else {
const previous = previousNonWhitespaceOffset(content, offset - 1);
if (content[previous] === ',') {
offset = previous;
}
}
return {
offset,
length: Math.max(0, end - offset),
content: '',
};
}
function collectDuplicatePropertyRemovalEdits(content: string, path: string): Edit[] {
const errors: ParseError[] = [];
let node = parseJsoncTree(content, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (!node || errors.length > 0) {
return [];
}
const edits: Edit[] = [];
for (const segment of pathToSegments(path)) {
const matches = objectProperties(node).filter((property) => propertyKey(property) === segment);
if (matches.length === 0) {
break;
}
for (const duplicate of matches.slice(0, -1)) {
edits.push(removalEditForProperty(content, duplicate));
}
node = propertyValue(matches[matches.length - 1]!);
}
return edits;
}
function applyRemovalEdits(content: string, edits: Edit[]): string {
return [...edits]
.sort((left, right) => right.offset - left.offset)
.reduce(
(current, edit) =>
`${current.slice(0, edit.offset)}${edit.content}${current.slice(edit.offset + edit.length)}`,
content,
);
}
function removeDuplicatePropertiesAlongPath(content: string, path: string): string {
const edits = collectDuplicatePropertyRemovalEdits(content, path);
return edits.length > 0 ? applyRemovalEdits(content, edits) : content;
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
+13 -5
View File
@@ -9,10 +9,7 @@ import {
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
const STARTUP_MIGRATION_EXCLUDED_PATHS = new Set([
'subtitleStyle.hoverTokenColor',
'subtitleStyle.hoverTokenBackgroundColor',
]);
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
export type LegacySubtitleStyleCssMigrationResult =
| {
@@ -54,6 +51,16 @@ function hasPath(root: unknown, path: string): boolean {
return false;
}
function isMigratableLegacySubtitleCssValue(path: string, value: unknown): boolean {
if (path === 'subtitleStyle.hoverTokenColor') {
return typeof value === 'string' && HEX_COLOR_PATTERN.test(value.trim());
}
if (path === 'subtitleStyle.hoverTokenBackgroundColor') {
return typeof value === 'string';
}
return true;
}
export function buildLegacySubtitleStyleCssMigrationOperations(
rawConfig: RawConfig,
): ConfigSettingsPatchOperation[] {
@@ -66,7 +73,8 @@ export function buildLegacySubtitleStyleCssMigrationOperations(
};
const legacyPaths = getSubtitleCssManagedConfigPaths(scope).filter(
(legacyPath) =>
!STARTUP_MIGRATION_EXCLUDED_PATHS.has(legacyPath) && hasPath(rawConfig, legacyPath),
hasPath(rawConfig, legacyPath) &&
isMigratableLegacySubtitleCssValue(legacyPath, getValueAtPath(rawConfig, legacyPath)),
);
if (legacyPaths.length === 0) continue;
+66
View File
@@ -6,11 +6,18 @@ import {
DEFAULT_KEYBINDINGS,
deepCloneConfig,
} from './definitions';
import {
buildSubtitleCssDeclarationObject,
getSubtitleCssManagedConfigPaths,
getSubtitleCssPath,
type SubtitleCssScope,
} from '../settings/subtitle-style-css';
const OPTION_REGISTRY_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
);
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
function normalizeCommentText(value: string): string {
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
@@ -18,7 +25,9 @@ function normalizeCommentText(value: string): string {
function humanizeKey(key: string): string {
const spaced = key
.replace(/^--/, '')
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase();
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -42,6 +51,62 @@ function buildInlineOptionComment(path: string, value: unknown): string {
return description;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function getValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
function setValueAtPath(root: unknown, path: string, value: unknown): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
current[segment] = value;
return;
}
current = current[segment];
}
}
function deleteValueAtPath(root: unknown, path: string): void {
const segments = path.split('.').filter(Boolean);
let current = root;
for (const [index, segment] of segments.entries()) {
if (!isRecord(current)) return;
if (index === segments.length - 1) {
delete current[segment];
return;
}
current = current[segment];
}
}
function foldSubtitleCssManagedDefaults(templateConfig: ResolvedConfig): void {
for (const scope of SUBTITLE_CSS_SCOPES) {
const cssPath = getSubtitleCssPath(scope);
const values: Record<string, unknown> = {
[cssPath]: getValueAtPath(templateConfig, cssPath),
};
const managedPaths = getSubtitleCssManagedConfigPaths(scope);
for (const managedPath of managedPaths) {
values[managedPath] = getValueAtPath(templateConfig, managedPath);
}
setValueAtPath(templateConfig, cssPath, buildSubtitleCssDeclarationObject(scope, values));
for (const managedPath of managedPaths) {
deleteValueAtPath(templateConfig, managedPath);
}
}
}
function renderValue(value: unknown, indent = 0, path = ''): string {
const pad = ' '.repeat(indent);
const nextPad = ' '.repeat(indent + 2);
@@ -106,6 +171,7 @@ function renderSection(
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
const templateConfig = deepCloneConfig(config);
foldSubtitleCssManagedDefaults(templateConfig);
if (templateConfig.keybindings.length === 0) {
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
key: binding.key,
+57 -5
View File
@@ -11,7 +11,8 @@ type WindowTrackerStub = {
isTargetWindowMinimized?: () => boolean;
};
function createMainWindowRecorder() {
function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {}) {
const emitShowImmediately = options.emitShowImmediately ?? true;
const calls: string[] = [];
const listeners = new Map<string, Array<() => void>>();
let visible = false;
@@ -25,6 +26,10 @@ function createMainWindowRecorder() {
handler();
}
};
const emitShow = (): void => {
visible = true;
emit('show');
};
const window = {
webContents: {},
isDestroyed: () => false,
@@ -39,14 +44,16 @@ function createMainWindowRecorder() {
calls.push('hide');
},
show: () => {
visible = true;
calls.push('show');
emit('show');
if (emitShowImmediately) {
emitShow();
}
},
showInactive: () => {
visible = true;
calls.push('show-inactive');
emit('show');
if (emitShowImmediately) {
emitShow();
}
},
focus: () => {
focused = true;
@@ -81,6 +88,7 @@ function createMainWindowRecorder() {
window,
calls,
getOpacity: () => opacity,
emitShow,
setContentReady: (nextContentReady: boolean) => {
contentReady = nextContentReady;
(
@@ -267,6 +275,50 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
);
});
test('tracked non-macOS overlay queues only one first-show bounds refresh', () => {
const { window, calls, emitShow } = createMainWindowRecorder({ emitShowImmediately: false });
let width = 1280;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width, height: 720 }),
};
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: (geometry: { width: number }) => {
calls.push(`update-bounds:${geometry.width}`);
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
width = 1440;
run();
emitShow();
assert.deepEqual(
calls.filter((call) => call.startsWith('update-bounds:')),
['update-bounds:1280', 'update-bounds:1440', 'update-bounds:1440'],
);
});
test('Windows visible overlay stays click-through and binds to mpv while tracked', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+11 -1
View File
@@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
BrowserWindow,
ReturnType<typeof setTimeout>
>();
const pendingFirstShowBoundsRefreshGeometry = new WeakMap<BrowserWindow, WindowGeometry>();
function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
const opacityCapableWindow = window as BrowserWindow & {
setOpacity?: (opacity: number) => void;
@@ -279,11 +280,20 @@ export function updateVisibleOverlayVisibility(args: {
) {
return;
}
if (pendingFirstShowBoundsRefreshGeometry.has(mainWindow)) {
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
return;
}
pendingFirstShowBoundsRefreshGeometry.set(mainWindow, geometry);
mainWindow.once('show', () => {
const pendingGeometry = pendingFirstShowBoundsRefreshGeometry.get(mainWindow);
pendingFirstShowBoundsRefreshGeometry.delete(mainWindow);
if (mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
args.updateVisibleOverlayBounds(geometry);
if (pendingGeometry) {
args.updateVisibleOverlayBounds(pendingGeometry);
}
});
};
+22 -4
View File
@@ -43,6 +43,7 @@ export interface TokenizerServiceDeps {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -74,6 +75,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
getNPlusOneEnabled?: () => boolean;
getJlptEnabled?: () => boolean;
@@ -88,6 +90,7 @@ export interface TokenizerDepsRuntimeOptions {
}
interface TokenizerAnnotationOptions {
knownWordsEnabled: boolean;
nPlusOneEnabled: boolean;
jlptEnabled: boolean;
nameMatchEnabled: boolean;
@@ -119,18 +122,28 @@ function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string) => boolean {
if (!options.nPlusOneEnabled) {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
return deps.isKnownWord;
}
function needsMecabPosEnrichment(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
return options.nPlusOneEnabled || options.jlptEnabled || options.frequencyEnabled;
return (
options.knownWordsEnabled ||
options.nPlusOneEnabled ||
options.jlptEnabled ||
options.frequencyEnabled
);
}
async function enrichTokensWithMecabAsync(
@@ -211,6 +224,7 @@ export function createTokenizerDepsRuntime(
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordMatchMode: options.getKnownWordMatchMode,
getKnownWordsEnabled: options.getKnownWordsEnabled,
getJlptLevel: options.getJlptLevel,
getNPlusOneEnabled: options.getNPlusOneEnabled,
getJlptEnabled: options.getJlptEnabled,
@@ -662,8 +676,12 @@ function applyFrequencyRanks(
}
function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOptions {
const nPlusOneEnabled = deps.getNPlusOneEnabled?.() !== false;
return {
nPlusOneEnabled: deps.getNPlusOneEnabled?.() !== false,
knownWordsEnabled: deps.getKnownWordsEnabled
? deps.getKnownWordsEnabled() !== false
: nPlusOneEnabled,
nPlusOneEnabled,
jlptEnabled: deps.getJlptEnabled?.() !== false,
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
@@ -56,6 +56,50 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
assert.equal(surfaceResult[0]?.isKnown, false);
});
test('annotateTokens marks known words when N+1 is disabled', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: false, knownWordsEnabled: true },
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.isNPlusOneTarget, false);
assert.equal(result[1]?.isKnown, true);
assert.equal(result[1]?.isNPlusOneTarget, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, false);
});
test('annotateTokens hides known-word marks while still using known words for N+1', () => {
const tokens = [
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
makeToken({ surface: '猫', headword: '猫', startPos: 1, endPos: 2 }),
makeToken({ surface: '犬', headword: '犬', startPos: 2, endPos: 3 }),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '私' || text === '猫',
}),
{ nPlusOneEnabled: true, knownWordsEnabled: false, minSentenceWordsForNPlusOne: 3 },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[1]?.isKnown, false);
assert.equal(result[2]?.isKnown, false);
assert.equal(result[2]?.isNPlusOneTarget, true);
});
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
const tokens = [
makeToken({
+24 -10
View File
@@ -31,6 +31,7 @@ export interface AnnotationStageDeps {
}
export interface AnnotationStageOptions {
knownWordsEnabled?: boolean;
nPlusOneEnabled?: boolean;
nameMatchEnabled?: boolean;
jlptEnabled?: boolean;
@@ -669,13 +670,16 @@ export function annotateTokens(
): MergedToken[] {
const pos1Exclusions = resolvePos1Exclusions(options);
const pos2Exclusions = resolvePos2Exclusions(options);
const knownWordsEnabled = options.knownWordsEnabled !== false;
const nPlusOneEnabled = options.nPlusOneEnabled !== false;
const nameMatchEnabled = options.nameMatchEnabled !== false;
const frequencyEnabled = options.frequencyEnabled !== false;
const jlptEnabled = options.jlptEnabled !== false;
const shouldComputeKnownStatus = knownWordsEnabled || nPlusOneEnabled;
const nPlusOneKnownStatuses: boolean[] = [];
// Single pass: compute known word status, frequency filtering, and JLPT level together
const annotated = tokens.map((token) => {
const annotated = tokens.map((token, index) => {
if (
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
pos1Exclusions,
@@ -686,6 +690,7 @@ export function annotateTokens(
pos1Exclusions,
pos2Exclusions,
});
nPlusOneKnownStatuses[index] = false;
return {
...strippedToken,
isKnown: false,
@@ -693,9 +698,10 @@ export function annotateTokens(
}
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
const isKnown = nPlusOneEnabled
const isKnownForMatching = shouldComputeKnownStatus
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const frequencyRank =
frequencyEnabled && !prioritizedNameMatch
@@ -709,7 +715,7 @@ export function annotateTokens(
return {
...token,
isKnown,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank,
jlptLevel,
@@ -728,13 +734,21 @@ export function annotateTokens(
? minSentenceWordsForNPlusOne
: 3;
const nPlusOneMarked = markNPlusOneTargets(
annotated,
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
);
const nPlusOneMarked = nPlusOneEnabled
? markNPlusOneTargets(
annotated.map((token, index) => ({
...token,
isKnown: nPlusOneKnownStatuses[index] ?? false,
})),
sanitizedMinSentenceWordsForNPlusOne,
pos1Exclusions,
pos2Exclusions,
options.sourceText,
).map((token, index) => ({
...annotated[index]!,
isNPlusOneTarget: token.isNPlusOneTarget,
}))
: annotated;
if (!nameMatchEnabled) {
return nPlusOneMarked;
+13 -3
View File
@@ -4270,6 +4270,11 @@ const {
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.knownWords.matchMode,
getKnownWordsEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
),
getNPlusOneEnabled: () =>
getRuntimeBooleanOption(
'subtitle.annotation.nPlusOne',
@@ -5292,12 +5297,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () =>
resolveCurrentSubtitleForRenderer({
tokenizeCurrentSubtitle: async () => {
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
return resolveCurrentSubtitleForRenderer({
currentSubText: appState.currentSubText,
currentSubtitleData: appState.currentSubtitleData,
withCurrentSubtitleTiming: (payload) => withCurrentSubtitleTiming(payload),
}),
tokenizeSubtitle: tokenizeSubtitleForCurrent
? (text) => tokenizeSubtitleForCurrent(text)
: undefined,
});
},
getCurrentSubtitleRaw: () => appState.currentSubText,
getCurrentSubtitleAss: () => appState.currentSubAssText,
getSubtitleSidebarSnapshot: async () => {
@@ -137,10 +137,13 @@ export function composeMpvRuntimeHandlers<
const shouldInitializeMecabForAnnotations = (): boolean => {
const nPlusOneEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getNPlusOneEnabled?.() !== false;
const knownWordsEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled
? options.tokenizer.buildTokenizerDepsMainDeps.getKnownWordsEnabled() !== false
: nPlusOneEnabled;
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
const frequencyEnabled =
options.tokenizer.buildTokenizerDepsMainDeps.getFrequencyDictionaryEnabled() !== false;
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
return knownWordsEnabled || nPlusOneEnabled || jlptEnabled || frequencyEnabled;
};
const shouldWarmupAnnotationDictionaries = (): boolean => {
const jlptEnabled = options.tokenizer.buildTokenizerDepsMainDeps.getJlptEnabled() !== false;
@@ -45,3 +45,16 @@ test('renderer current subtitle snapshot falls back to raw text for uncached sub
assert.equal(payload.startTime, 1);
assert.equal(payload.tokens, null);
});
test('renderer current subtitle snapshot tokenizes uncached subtitles when tokenizer is available', async () => {
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '新しい字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '新' } as never] }),
});
assert.equal(payload.text, '新しい字幕');
assert.equal(payload.startTime, 1);
assert.deepEqual(payload.tokens, [{ text: '新' }]);
});
@@ -4,6 +4,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubText: string;
currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
}): Promise<SubtitleData> {
if (deps.currentSubtitleData?.text === deps.currentSubText) {
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
@@ -16,6 +17,11 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
});
}
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) {
return deps.withCurrentSubtitleTiming(tokenized);
}
return deps.withCurrentSubtitleTiming({
text: deps.currentSubText,
tokens: null,
@@ -30,6 +30,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
isKnownWord: (text) => text === 'known',
recordLookup: (hit) => calls.push(`lookup:${hit}`),
getKnownWordMatchMode: () => 'surface',
getKnownWordsEnabled: () => true,
getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
@@ -47,6 +48,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
assert.equal(deps.getKnownWordsEnabled?.(), true);
assert.equal(deps.getNPlusOneEnabled?.(), true);
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
assert.equal(deps.getNameMatchEnabled?.(), false);
@@ -38,6 +38,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
return hit;
},
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
...(deps.getKnownWordsEnabled
? {
getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(),
}
: {}),
...(deps.getNPlusOneEnabled
? {
getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(),
+6 -6
View File
@@ -2,17 +2,17 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as ankiControls from './settings-anki-controls';
test('note field model preference keeps a matching configured model before Kiku fallback', () => {
test('note field model preference ignores configured sentence-card model before Kiku fallback', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'Lapis Morph'),
'Lapis Morph',
'Kiku',
);
});
test('note field model preference matches configured model case-insensitively', () => {
test('note field model preference ignores configured sentence-card model case-insensitively', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Lapis Morph', 'Kiku'], 'lapis morph'),
'Lapis Morph',
'Kiku',
);
});
@@ -28,10 +28,10 @@ test('note field model preference does not treat partial Kiku matches as Kiku',
assert.equal(ankiControls.selectPreferredNoteFieldModelName(['Kikuchi', 'Mining'], ''), '');
});
test('note field model preference accepts partial Lapis matches', () => {
test('note field model preference does not treat partial Lapis matches as Lapis', () => {
assert.equal(
ankiControls.selectPreferredNoteFieldModelName(['Mining', 'Lapis Morph'], ''),
'Lapis Morph',
'',
);
});
+8 -22
View File
@@ -46,39 +46,25 @@ export function configureAnkiControls(options: { requestRender: () => void }): v
requestRender = options.requestRender;
}
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
if (
!state.noteFieldModelName &&
!state.noteFieldModelNameManuallySelected &&
typeof configuredNoteType === 'string'
) {
state.noteFieldModelName = configuredNoteType;
}
export function initializeAnkiControls(_values: Record<string, ConfigSettingsSnapshotValue>): void {
state.noteFieldModelName = '';
state.noteFieldModelNameManuallySelected = false;
}
export function selectPreferredNoteFieldModelName(
modelNames: readonly string[],
currentModelName = '',
): string {
const normalizedCurrentModelName = currentModelName.trim().toLowerCase();
if (normalizedCurrentModelName) {
const currentModel = modelNames.find(
(name) => name.toLowerCase() === normalizedCurrentModelName,
);
if (currentModel) {
return currentModel;
}
}
void currentModelName;
const exactKiku = modelNames.find((name) => name.toLowerCase() === 'kiku');
const exactKiku = modelNames.find((name) => name.trim().toLowerCase() === 'kiku');
if (exactKiku) {
return exactKiku;
}
const lapis = modelNames.find((name) => name.toLowerCase().includes('lapis'));
if (lapis) {
return lapis;
const exactLapis = modelNames.find((name) => name.trim().toLowerCase() === 'lapis');
if (exactLapis) {
return exactLapis;
}
return '';
-1
View File
@@ -87,7 +87,6 @@ export interface AnkiConnectConfig {
};
nPlusOne?: {
enabled?: boolean;
nPlusOne?: string;
minSentenceWords?: number;
};
behavior?: {