diff --git a/changes/config-example-settings-css.md b/changes/config-example-settings-css.md new file mode 100644 index 00000000..d0b2b787 --- /dev/null +++ b/changes/config-example-settings-css.md @@ -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. diff --git a/changes/note-fields-default-note-type.md b/changes/note-fields-default-note-type.md index f9ec42b5..9f46ec63 100644 --- a/changes/note-fields-default-note-type.md +++ b/changes/note-fields-default-note-type.md @@ -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. diff --git a/changes/subtitle-css-hover-migration.md b/changes/subtitle-css-hover-migration.md new file mode 100644 index 00000000..73f47a20 --- /dev/null +++ b/changes/subtitle-css-hover-migration.md @@ -0,0 +1,4 @@ +type: fixed +area: config + +- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind. diff --git a/config.example.jsonc b/config.example.jsonc index b7883f0f..01efc1ba 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 95dee4ee..3f49d8f4 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -8,10 +8,6 @@ outline: [2, 3] import { withBase } from 'vitepress'; -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=` on launcher/app invocations when needed. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index b7883f0f..01efc1ba 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. // ========================================== diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 57b8bb08..58fdf504 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -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 diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 064c9ef4..02da0d8e 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -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 = "", diff --git a/src/config/anki-connect-nplusone-migration.ts b/src/config/anki-connect-nplusone-migration.ts new file mode 100644 index 00000000..aeee284d --- /dev/null +++ b/src/config/anki-connect-nplusone-migration.ts @@ -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 = {}; + 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(); + const legacyValues = new Map(); + 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, + }; +} diff --git a/src/config/config.test.ts b/src/config/config.test.ts index cffc1921..0c4a708f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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)[segment]; + } + return current; +} + +function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record { + const values: Record = { + [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; 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 }; + 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; + nPlusOne?: Record; + }; + 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 }; + }; + + 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 { diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index a8fba1e8..cea45837 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick< notificationType: 'system', channel: 'stable', }, - auto_start_overlay: false, + auto_start_overlay: true, }; diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index fd263bac..2db4a7c6 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -654,7 +654,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void { const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record) : {}; 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 = {}; for (const [deck, fields] of Object.entries(knownWordsDecks as Record)) { @@ -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 = {}; - 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) : {}; - 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 ( diff --git a/src/config/resolve/subtitle-domains.ts b/src/config/resolve/subtitle-domains.ts index 4f18c0aa..1ca8eda1 100644 --- a/src/config/resolve/subtitle-domains.ts +++ b/src/config/resolve/subtitle-domains.ts @@ -25,6 +25,23 @@ function asCssDeclarations(value: unknown): Record | 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, ); diff --git a/src/config/resolve/subtitle-style.test.ts b/src/config/resolve/subtitle-style.test.ts index 3fde4003..8561c5cf 100644 --- a/src/config/resolve/subtitle-style.test.ts +++ b/src/config/resolve/subtitle-style.test.ts @@ -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', }); diff --git a/src/config/service.ts b/src/config/service.ts index 5442d165..2719d8ea 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -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; } diff --git a/src/config/settings/jsonc-edit.test.ts b/src/config/settings/jsonc-edit.test.ts index 516a2468..12d8ffaa 100644 --- a/src/config/settings/jsonc-edit.test.ts +++ b/src/config/settings/jsonc-edit.test.ts @@ -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": { diff --git a/src/config/settings/jsonc-edit.ts b/src/config/settings/jsonc-edit.ts index 66851f69..08be7fd7 100644 --- a/src/config/settings/jsonc-edit.ts +++ b/src/config/settings/jsonc-edit.ts @@ -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[], diff --git a/src/config/subtitle-style-css-migration.ts b/src/config/subtitle-style-css-migration.ts index 3d01b66c..f87156b9 100644 --- a/src/config/subtitle-style-css-migration.ts +++ b/src/config/subtitle-style-css-migration.ts @@ -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; diff --git a/src/config/template.ts b/src/config/template.ts index 15d966dd..6f9e5613 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -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 { + 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 = { + [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, diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 6eca9e44..bed34537 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -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 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 = { diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index d9f514c5..aef9003b 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -8,6 +8,7 @@ const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap< BrowserWindow, ReturnType >(); +const pendingFirstShowBoundsRefreshGeometry = new WeakMap(); 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); + } }); }; diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 63be5eba..6b93bcfe 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -43,6 +43,7 @@ export interface TokenizerServiceDeps { setYomitanParserInitPromise: (promise: Promise | 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 | 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, diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index df394027..544147ad 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -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({ diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index ea9c142f..73313d05 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 121c9231..34c242ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4226,6 +4226,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', @@ -5232,12 +5237,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 () => { diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts index 7a4ce82b..22f8ede9 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -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; diff --git a/src/main/runtime/current-subtitle-snapshot.test.ts b/src/main/runtime/current-subtitle-snapshot.test.ts index aa449b8f..5d7d66b0 100644 --- a/src/main/runtime/current-subtitle-snapshot.test.ts +++ b/src/main/runtime/current-subtitle-snapshot.test.ts @@ -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: '新' }]); +}); diff --git a/src/main/runtime/current-subtitle-snapshot.ts b/src/main/runtime/current-subtitle-snapshot.ts index c24b8150..7aeb5c46 100644 --- a/src/main/runtime/current-subtitle-snapshot.ts +++ b/src/main/runtime/current-subtitle-snapshot.ts @@ -4,6 +4,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: { currentSubText: string; currentSubtitleData: SubtitleData | null; withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData; + tokenizeSubtitle?: (text: string) => Promise; }): Promise { 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, diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts index 64a74e65..7cfafe12 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -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); diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index 1e27d192..a6dbd86f 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -38,6 +38,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { return hit; }, getKnownWordMatchMode: () => deps.getKnownWordMatchMode(), + ...(deps.getKnownWordsEnabled + ? { + getKnownWordsEnabled: () => deps.getKnownWordsEnabled!(), + } + : {}), ...(deps.getNPlusOneEnabled ? { getNPlusOneEnabled: () => deps.getNPlusOneEnabled!(), diff --git a/src/settings/settings-anki-controls.test.ts b/src/settings/settings-anki-controls.test.ts index d03d689d..765d0473 100644 --- a/src/settings/settings-anki-controls.test.ts +++ b/src/settings/settings-anki-controls.test.ts @@ -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', + '', ); }); diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts index cae72dce..38e4e18f 100644 --- a/src/settings/settings-anki-controls.ts +++ b/src/settings/settings-anki-controls.ts @@ -46,39 +46,25 @@ export function configureAnkiControls(options: { requestRender: () => void }): v requestRender = options.requestRender; } -export function initializeAnkiControls(values: Record): void { - const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel']; - if ( - !state.noteFieldModelName && - !state.noteFieldModelNameManuallySelected && - typeof configuredNoteType === 'string' - ) { - state.noteFieldModelName = configuredNoteType; - } +export function initializeAnkiControls(_values: Record): 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 ''; diff --git a/src/types/anki.ts b/src/types/anki.ts index ba114bd0..e83e374d 100644 --- a/src/types/anki.ts +++ b/src/types/anki.ts @@ -87,7 +87,6 @@ export interface AnkiConnectConfig { }; nPlusOne?: { enabled?: boolean; - nPlusOne?: string; minSentenceWords?: number; }; behavior?: {