From eb04ea97b1abeed4bca287b36c941ab099699da9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 27 May 2026 00:12:21 -0700 Subject: [PATCH] fix: Kiku field grouping, frequency particles, sidebar media, Yomitan po MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Kiku duplicate-card field grouping: local dupes trigger manual modal or auto-merge; fix modal-open ack race; fix merged field ordering, sentence-audio, furigana, and tag semantics - Fix frequency annotations for single-token Yomitan compounds with internal particles (e.g. 目の前); keep pure grammar/kana spans unannotated - Fix subtitle sidebar mining: use audio/image from clicked sidebar line, not current primary line - Add `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitle visible while Yomitan popup is open - Normalize trailing commas in config.example.jsonc --- changes/fix-kiku-duplicate-modal-open.md | 4 + changes/frequency-compound-particles.md | 1 + changes/primary-visible-yomitan-popup.md | 4 + changes/subtitle-sidebar-mining-media.md | 4 + config.example.jsonc | 231 +++++-------- docs-site/configuration.md | 313 +++++++++--------- docs-site/public/config.example.jsonc | 231 +++++-------- src/anki-integration.test.ts | 67 +++- src/anki-integration.ts | 129 ++++++-- .../field-grouping-merge.test.ts | 109 ++++-- src/anki-integration/field-grouping-merge.ts | 157 +++------ .../field-grouping-workflow.test.ts | 31 ++ .../field-grouping-workflow.ts | 25 ++ .../note-update-workflow.test.ts | 70 ++++ src/anki-integration/note-update-workflow.ts | 78 ++++- src/config/config.test.ts | 39 +++ src/config/definitions/defaults-subtitle.ts | 1 + src/config/definitions/options-subtitle.ts | 7 + src/config/resolve/subtitle-domains.ts | 23 ++ src/config/resolve/subtitle-style.test.ts | 27 ++ src/config/settings/registry.test.ts | 11 +- src/config/settings/registry.ts | 10 +- .../services/field-grouping-overlay.test.ts | 126 +++++++ src/core/services/field-grouping-overlay.ts | 51 ++- src/core/services/field-grouping.ts | 46 ++- src/core/services/ipc.test.ts | 37 +++ src/core/services/ipc.ts | 44 ++- src/core/services/overlay-bridge.ts | 11 +- src/core/services/tokenizer.test.ts | 60 ++++ .../services/tokenizer/annotation-stage.ts | 38 ++- src/main.ts | 21 +- src/main/dependencies.ts | 2 + src/main/overlay-runtime.test.ts | 22 ++ src/main/overlay-runtime.ts | 7 + .../field-grouping-overlay-main-deps.ts | 4 +- src/main/runtime/log-export.ts | 27 ++ src/preload.ts | 5 +- src/renderer/handlers/mouse.test.ts | 83 +++++ src/renderer/handlers/mouse.ts | 13 + src/renderer/modals/subtitle-sidebar.test.ts | 82 +++++ src/renderer/modals/subtitle-sidebar.ts | 85 ++++- src/renderer/renderer.ts | 2 +- src/renderer/state.ts | 2 + src/renderer/style.css | 4 + src/renderer/subtitle-render.test.ts | 6 + src/renderer/subtitle-render.ts | 10 + src/renderer/yomitan-popup.ts | 1 + src/types/runtime.ts | 3 +- src/types/subtitle.ts | 9 + 49 files changed, 1711 insertions(+), 662 deletions(-) create mode 100644 changes/fix-kiku-duplicate-modal-open.md create mode 100644 changes/frequency-compound-particles.md create mode 100644 changes/primary-visible-yomitan-popup.md create mode 100644 changes/subtitle-sidebar-mining-media.md diff --git a/changes/fix-kiku-duplicate-modal-open.md b/changes/fix-kiku-duplicate-modal-open.md new file mode 100644 index 00000000..2ab71fd7 --- /dev/null +++ b/changes/fix-kiku-duplicate-modal-open.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics. diff --git a/changes/frequency-compound-particles.md b/changes/frequency-compound-particles.md new file mode 100644 index 00000000..81cb5027 --- /dev/null +++ b/changes/frequency-compound-particles.md @@ -0,0 +1 @@ +- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated. diff --git a/changes/primary-visible-yomitan-popup.md b/changes/primary-visible-yomitan-popup.md new file mode 100644 index 00000000..3bd42332 --- /dev/null +++ b/changes/primary-visible-yomitan-popup.md @@ -0,0 +1,4 @@ +type: added +area: config + +- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open. diff --git a/changes/subtitle-sidebar-mining-media.md b/changes/subtitle-sidebar-mining-media.md new file mode 100644 index 00000000..40a26f8d --- /dev/null +++ b/changes/subtitle-sidebar-mining-media.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line. diff --git a/config.example.jsonc b/config.example.jsonc index 7e0078e2..8e34798a 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { - // ========================================== // Visible Overlay Auto-Start // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. @@ -19,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false + "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -29,7 +28,7 @@ // ========================================== "websocket": { "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -39,7 +38,7 @@ // ========================================== "annotationWebsocket": { "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false - "port": 6678 // Annotated subtitle websocket server port. + "port": 6678, // Annotated subtitle websocket server port. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. // ========================================== @@ -54,8 +53,8 @@ "files": { "app": true, // Write SubMiner app runtime logs. Values: true | false "launcher": true, // Write launcher command logs. Values: true | false - "mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false - } // Files setting. + "mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false + }, // Files setting. }, // Controls logging verbosity. // ========================================== @@ -88,66 +87,66 @@ "leftStickPress": 9, // Raw button index used for controller L3 input. "rightStickPress": 10, // Raw button index used for controller R3 input. "leftTrigger": 6, // Raw button index used for controller L2 input. - "rightTrigger": 7 // Raw button index used for controller R2 input. + "rightTrigger": 7, // Raw button index used for controller R2 input. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors. "bindings": { "toggleLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 0 // Raw button index captured for this discrete controller action. + "buttonIndex": 0, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "closeLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 1 // Raw button index captured for this discrete controller action. + "buttonIndex": 1, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleKeyboardOnlyMode": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 3 // Raw button index captured for this discrete controller action. + "buttonIndex": 3, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "mineCard": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 2 // Raw button index captured for this discrete controller action. + "buttonIndex": 2, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "quitMpv": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 6 // Raw button index captured for this discrete controller action. + "buttonIndex": 6, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousAudio": { - "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "nextAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 5 // Raw button index captured for this discrete controller action. + "buttonIndex": 5, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "playCurrentAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 4 // Raw button index captured for this discrete controller action. + "buttonIndex": 4, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleMpvPause": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 9 // Raw button index captured for this discrete controller action. + "buttonIndex": 9, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "leftStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. - "dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. "leftStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 1, // Raw axis index captured for this analog controller action. - "dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 3, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 4, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical - } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. - "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. + "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== @@ -161,7 +160,7 @@ "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false - "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false + "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // ========================================== @@ -173,7 +172,7 @@ "enabled": true, // Run automatic update checks in the background. Values: true | false "checkIntervalHours": 24, // Minimum hours between automatic update checks. "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none - "channel": "stable" // Release channel used for update checks. Values: stable | prerelease + "channel": "stable", // Release channel used for update checks. Values: stable | prerelease }, // Automatic update check behavior. // ========================================== @@ -199,7 +198,7 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -211,122 +210,76 @@ "keybindings": [ { "key": "Space", // Key setting. - "command": [ - "cycle", - "pause" - ] // Command setting. + "command": ["cycle", "pause"], // Command setting. }, { "key": "KeyF", // Key setting. - "command": [ - "cycle", - "fullscreen" - ] // Command setting. + "command": ["cycle", "fullscreen"], // Command setting. }, { "key": "KeyJ", // Key setting. - "command": [ - "cycle", - "sid" - ] // Command setting. + "command": ["cycle", "sid"], // Command setting. }, { "key": "Shift+KeyJ", // Key setting. - "command": [ - "cycle", - "secondary-sid" - ] // Command setting. + "command": ["cycle", "secondary-sid"], // Command setting. }, { "key": "ArrowRight", // Key setting. - "command": [ - "seek", - 5 - ] // Command setting. + "command": ["seek", 5], // Command setting. }, { "key": "ArrowLeft", // Key setting. - "command": [ - "seek", - -5 - ] // Command setting. + "command": ["seek", -5], // Command setting. }, { "key": "ArrowUp", // Key setting. - "command": [ - "seek", - 60 - ] // Command setting. + "command": ["seek", 60], // Command setting. }, { "key": "ArrowDown", // Key setting. - "command": [ - "seek", - -60 - ] // Command setting. + "command": ["seek", -60], // Command setting. }, { "key": "Shift+KeyH", // Key setting. - "command": [ - "sub-seek", - -1 - ] // Command setting. + "command": ["sub-seek", -1], // Command setting. }, { "key": "Shift+KeyL", // Key setting. - "command": [ - "sub-seek", - 1 - ] // Command setting. + "command": ["sub-seek", 1], // Command setting. }, { "key": "Shift+BracketRight", // Key setting. - "command": [ - "__sub-delay-next-line" - ] // Command setting. + "command": ["__sub-delay-next-line"], // Command setting. }, { "key": "Shift+BracketLeft", // Key setting. - "command": [ - "__sub-delay-prev-line" - ] // Command setting. + "command": ["__sub-delay-prev-line"], // Command setting. }, { "key": "Ctrl+Alt+KeyC", // Key setting. - "command": [ - "__youtube-picker-open" - ] // Command setting. + "command": ["__youtube-picker-open"], // Command setting. }, { "key": "Ctrl+Alt+KeyP", // Key setting. - "command": [ - "__playlist-browser-open" - ] // Command setting. + "command": ["__playlist-browser-open"], // Command setting. }, { "key": "Ctrl+Shift+KeyH", // Key setting. - "command": [ - "__replay-subtitle" - ] // Command setting. + "command": ["__replay-subtitle"], // Command setting. }, { "key": "Ctrl+Shift+KeyL", // Key setting. - "command": [ - "__play-next-subtitle" - ] // Command setting. + "command": ["__play-next-subtitle"], // Command setting. }, { "key": "KeyQ", // Key setting. - "command": [ - "quit" - ] // Command setting. + "command": ["quit"], // Command setting. }, { "key": "Ctrl+KeyW", // Key setting. - "command": [ - "quit" - ] // Command setting. - } + "command": ["quit"], // Command setting. + }, ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== @@ -338,7 +291,7 @@ "secondarySub": { "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false - "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover + "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -350,7 +303,7 @@ "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. - "replace": true // Replace the active subtitle file when sync completes. Values: true | false + "replace": true, // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. // ========================================== @@ -358,7 +311,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. + "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -383,12 +336,13 @@ "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": "transparent" // Subtitle hover token background color setting. + "--subtitle-hover-token-background-color": "transparent", // 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 + "primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched 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. @@ -399,7 +353,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#8bd5ca", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -408,13 +362,7 @@ "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. - "bandedColors": [ - "#ed8796", - "#f5a97f", - "#f9e2af", - "#8bd5ca", - "#8aadf4" - ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). }, // Frequency dictionary setting. "secondary": { "css": { @@ -430,9 +378,9 @@ "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. + "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. // ========================================== @@ -457,8 +405,8 @@ "--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. + "--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. // ========================================== @@ -472,7 +420,7 @@ "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. - "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. + "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // ========================================== @@ -490,11 +438,9 @@ "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy. - "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. + "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. }, // Proxy setting. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "fields": { "word": "Expression", // Card field for the mined word or expression text. @@ -502,12 +448,12 @@ "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). - "translation": "SelectionText" // Card field that receives the current selection or translated text. + "translation": "SelectionText", // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows. - "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. + "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false @@ -524,14 +470,14 @@ "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface - "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. + "decks": {}, // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -539,24 +485,24 @@ "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false - "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). + "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false - "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. + "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -569,7 +515,7 @@ "apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc. "apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -578,10 +524,7 @@ // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== @@ -599,9 +542,9 @@ "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false - "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - } // Collapsible sections setting. - } // Character dictionary setting. + "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + }, // Collapsible sections setting. + }, // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -612,7 +555,7 @@ // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // ========================================== "yomitan": { - "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. // ========================================== @@ -634,7 +577,7 @@ "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false - "aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. + "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible. }, // SubMiner-managed mpv launch and bundled plugin options. // ========================================== @@ -655,16 +598,8 @@ "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== @@ -676,7 +611,7 @@ "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. - "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + "debounceMs": 750, // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. // ========================================== @@ -701,13 +636,13 @@ "sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. - "vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. + "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable. }, // Retention setting. "lifetimeSummaries": { "global": true, // Maintain global lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false - "media": true // Maintain per-media lifetime stats rows. Values: true | false - } // Lifetime summaries setting. + "media": true, // Maintain per-media lifetime stats rows. Values: true | false + }, // Lifetime summaries setting. }, // Enable/disable immersion tracking. // ========================================== @@ -720,6 +655,6 @@ "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "serverPort": 6969, // Port for the stats HTTP server. "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false - "autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false - } // Local immersion stats dashboard served on localhost and available as an in-app overlay. + "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false + }, // Local immersion stats dashboard served on localhost and available as an in-app overlay. } diff --git a/docs-site/configuration.md b/docs-site/configuration.md index ba5fa25c..f426fefa 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -272,8 +272,8 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| --------- | ------------------------- | --------------------------------------------------- | +| Option | Values | Description | +| ------------------- | ------------------------- | --------------------------------------------------- | | `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) | | `websocket.port` | number | WebSocket server port (default: 6677) | @@ -292,8 +292,8 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen } ``` -| Option | Values | Description | -| --------- | --------------- | -------------------------------------------------------------- | +| Option | Values | Description | +| ----------------------------- | --------------- | -------------------------------------------------------------- | | `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) | @@ -358,34 +358,35 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. | -| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. | -| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | -| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | -| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | -| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | -| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) | -| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` 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`) | -| `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) | -| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | -| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | -| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | -| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | -| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | +| Option | Values | Description | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. | +| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. | +| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | +| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | +| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | +| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | +| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). | +| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) | +| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` 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`) | +| `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) | +| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | +| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) | +| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | +| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | +| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | Subtitle CSS custom properties: -| CSS Property | Default | Description | -| --------------------------------------------- | ------------- | ---------------------------------------- | -| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color | -| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color | +| CSS Property | Default | Description | +| ----------------------------------------- | ------------- | --------------------------------------- | +| `--subtitle-hover-token-color` | `#f4dbd6` | Hovered subtitle token text color | +| `--subtitle-hover-token-background-color` | `transparent` | Hovered subtitle token background color | The Settings window keeps subtitle color controls separate, then saves CSS textboxes to the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example @@ -438,25 +439,25 @@ Configure the parsed-subtitle sidebar modal. } ``` -| Option | Values | Description | -| ------------------- | ------- | ------------------------------------------------------------------------------------------------------- | -| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) | -| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | -| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | +| Option | Values | Description | +| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------- | +| `subtitleSidebar.enabled` | boolean | Enable subtitle sidebar support (`true` by default) | +| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | +| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `subtitleSidebar.toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) | -| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) | -| `autoScroll` | boolean | Keep the active cue in view while playback advances | -| `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. | +| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) | +| `autoScroll` | boolean | Keep the active cue in view while playback advances | +| `subtitleSidebar.css` | object | CSS declaration object applied to the sidebar. Use CSS properties plus sidebar custom properties below. | Sidebar CSS custom properties: -| CSS Property | Default | Description | -| ------------------------------------------------- | ------------------------------- | ------------------------------------- | -| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | -| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | -| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | -| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color | -| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | +| CSS Property | Default | Description | +| -------------------------------------------- | --------------------------- | ---------------------------- | +| `--subtitle-sidebar-max-width` | `420px` | Maximum sidebar width | +| `--subtitle-sidebar-timestamp-color` | `#a5adcb` | Cue timestamp color | +| `--subtitle-sidebar-active-line-color` | `#f5bde6` | Active cue text color | +| `--subtitle-sidebar-active-background-color` | `rgba(138, 173, 244, 0.22)` | Active cue background color | +| `--subtitle-sidebar-hover-background-color` | `rgba(54, 58, 79, 0.84)` | Hovered cue background color | The sidebar is only available when the active subtitle source has been parsed into a cue list. Default colors use Catppuccin Macchiato with a semi-transparent shell so the panel stays readable without feeling like an opaque settings dialog. @@ -621,26 +622,26 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | -| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | -| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | -| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | -| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | -| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | -| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | -| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | -| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | -| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | -| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | -| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | -| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | -| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | -| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | -| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | -| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | +| Option | Values | Description | +| -------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | +| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | +| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | +| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | +| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) | +| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | +| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | +| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | +| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | +| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | +| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | +| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | +| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | +| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | **See `config.example.jsonc`** for the complete list of shortcut configuration options. @@ -768,19 +769,19 @@ Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, When automatic card updates are disabled, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: -| Shortcut | Action | -| -------------- | ------------------------------------------------------------------------------------------------------------------ | -| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | -| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | -| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | -| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) | -| `Ctrl+S` | Create a sentence card from the current subtitle line | -| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | -| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | -| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | -| `Ctrl+D` | Open loaded character dictionary manager | -| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | -| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | +| Shortcut | Action | +| -------------- | ------------------------------------------------------------------------------------------------------------- | +| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | +| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | +| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | +| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when automatic card updates are disabled) | +| `Ctrl+S` | Create a sentence card from the current subtitle line | +| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | +| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | +| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | +| `Ctrl+D` | Open loaded character dictionary manager | +| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | +| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | **Multi-line copy workflow:** @@ -855,7 +856,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf | Option | Values | Description | | ------------------ | -------------------- | ------------------------------------------------------------------------------------ | -| `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) | +| `ai.enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) | | `apiKey` | string | Static API key for the shared provider | | `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) | | `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) | @@ -939,57 +940,57 @@ This example is intentionally compact. The option table below documents availabl **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. -| Option | Values | Description | -| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | -| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | -| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | -| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | -| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | -| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | -| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | -| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | -| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. | -| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | -| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | -| `fields.image` | string | Card field for images (default: `Picture`) | -| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | -| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | -| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | -| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | -| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | -| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | -| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | -| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | -| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | -| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | -| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | -| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | -| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | -| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | -| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | -| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | -| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | -| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | -| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | -| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | -| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | +| Option | Values | Description | +| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | +| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | +| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | +| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) | +| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) | +| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) | +| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) | +| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | +| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. | +| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | +| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | +| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | +| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. | +| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | +| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | +| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | +| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | +| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | +| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | +| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | +| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | +| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | +| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | +| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | +| `media.audioPadding` | number (seconds) | Optional padding around audio clip timing (default: `0`). Animated AVIF clips freeze the first frame during leading audio padding. | +| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | +| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | -| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | -| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | -| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | -| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | -| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | -| `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"] }`). | -| `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`) | -| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | -| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | -| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | +| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | +| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | +| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | +| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | +| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) | +| `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"] }`). | +| `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`) | +| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | +| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | +| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | `ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides. API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config. @@ -1144,15 +1145,15 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin } ``` -| Option | Values | Description | -| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | -| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | -| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | -| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | -| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | -| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | -| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | -| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile | +| Option | Values | Description | +| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- | +| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | +| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | +| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries | +| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries | +| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries | +| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile | When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. @@ -1243,21 +1244,21 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner } ``` -| Option | Values | Description | -| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | -| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | -| `serverUrl` | string (URL) | Jellyfin server base URL | -| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | -| `username` | string | Default username used by `--jellyfin-login` | -| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | -| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | +| Option | Values | Description | +| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------ | +| `jellyfin.enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | +| `serverUrl` | string (URL) | Jellyfin server base URL | +| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 | +| `username` | string | Default username used by `--jellyfin-login` | +| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | +| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | | `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires Jellyfin integration and remote control) | -| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | -| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | -| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | -| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | -| `directPlayContainers` | string[] | Container allowlist for direct play decisions | -| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | +| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | +| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | +| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | +| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | +| `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. SubMiner reports the Jellyfin client as `SubMiner`, derives the Jellyfin device id and visible device name from the OS hostname, and owns the client version internally. The Settings window also hides low-level default library fields (`defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior. @@ -1295,12 +1296,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ } ``` -| Option | Values | Description | -| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | +| Option | Values | Description | +| ------------------------- | ------------------------------------------------ | ---------------------------------------------------------- | | `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | -| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | -| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | -| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | +| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | +| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | +| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | Setup steps: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 7e0078e2..8e34798a 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -5,7 +5,6 @@ * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS. */ { - // ========================================== // Visible Overlay Auto-Start // Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner. @@ -19,7 +18,7 @@ // ========================================== "texthooker": { "launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false - "openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false + "openBrowser": false, // Open the texthooker page in the default browser when the server starts. Values: true | false }, // Configure texthooker startup launch and browser opening behavior. // ========================================== @@ -29,7 +28,7 @@ // ========================================== "websocket": { "enabled": false, // Built-in subtitle websocket server mode. Values: auto | true | false - "port": 6677 // Built-in subtitle websocket server port. + "port": 6677, // Built-in subtitle websocket server port. }, // Built-in WebSocket server broadcasts subtitle text to connected clients. // ========================================== @@ -39,7 +38,7 @@ // ========================================== "annotationWebsocket": { "enabled": false, // Annotated subtitle websocket server enabled state. Values: true | false - "port": 6678 // Annotated subtitle websocket server port. + "port": 6678, // Annotated subtitle websocket server port. }, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients. // ========================================== @@ -54,8 +53,8 @@ "files": { "app": true, // Write SubMiner app runtime logs. Values: true | false "launcher": true, // Write launcher command logs. Values: true | false - "mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false - } // Files setting. + "mpv": false, // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false + }, // Files setting. }, // Controls logging verbosity. // ========================================== @@ -88,66 +87,66 @@ "leftStickPress": 9, // Raw button index used for controller L3 input. "rightStickPress": 10, // Raw button index used for controller R3 input. "leftTrigger": 6, // Raw button index used for controller L2 input. - "rightTrigger": 7 // Raw button index used for controller R2 input. + "rightTrigger": 7, // Raw button index used for controller R2 input. }, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors. "bindings": { "toggleLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 0 // Raw button index captured for this discrete controller action. + "buttonIndex": 0, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "closeLookup": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 1 // Raw button index captured for this discrete controller action. + "buttonIndex": 1, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleKeyboardOnlyMode": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 3 // Raw button index captured for this discrete controller action. + "buttonIndex": 3, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "mineCard": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 2 // Raw button index captured for this discrete controller action. + "buttonIndex": 2, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "quitMpv": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 6 // Raw button index captured for this discrete controller action. + "buttonIndex": 6, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "previousAudio": { - "kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis + "kind": "none", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis }, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "nextAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 5 // Raw button index captured for this discrete controller action. + "buttonIndex": 5, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "playCurrentAudio": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 4 // Raw button index captured for this discrete controller action. + "buttonIndex": 4, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "toggleMpvPause": { "kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis - "buttonIndex": 9 // Raw button index captured for this discrete controller action. + "buttonIndex": 9, // Raw button index captured for this discrete controller action. }, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required. "leftStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 0, // Raw axis index captured for this analog controller action. - "dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "horizontal", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually. "leftStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 1, // Raw axis index captured for this analog controller action. - "dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "vertical", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickHorizontal": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 3, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical }, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually. "rightStickVertical": { "kind": "axis", // Analog binding input source kind. Values: none | axis "axisIndex": 4, // Raw axis index captured for this analog controller action. - "dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical - } // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. + "dpadFallback": "none", // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical + }, // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually. }, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction. - "profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. + "profiles": {}, // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API. }, // Gamepad support for the visible overlay while keyboard-only mode is active. // ========================================== @@ -161,7 +160,7 @@ "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false - "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false + "jellyfinRemoteSession": false, // Warm up Jellyfin remote session at startup. Values: true | false }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. // ========================================== @@ -173,7 +172,7 @@ "enabled": true, // Run automatic update checks in the background. Values: true | false "checkIntervalHours": 24, // Minimum hours between automatic update checks. "notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none - "channel": "stable" // Release channel used for update checks. Values: stable | prerelease + "channel": "stable", // Release channel used for update checks. Values: stable | prerelease }, // Automatic update check behavior. // ========================================== @@ -199,7 +198,7 @@ "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. - "toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility. + "toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== @@ -211,122 +210,76 @@ "keybindings": [ { "key": "Space", // Key setting. - "command": [ - "cycle", - "pause" - ] // Command setting. + "command": ["cycle", "pause"], // Command setting. }, { "key": "KeyF", // Key setting. - "command": [ - "cycle", - "fullscreen" - ] // Command setting. + "command": ["cycle", "fullscreen"], // Command setting. }, { "key": "KeyJ", // Key setting. - "command": [ - "cycle", - "sid" - ] // Command setting. + "command": ["cycle", "sid"], // Command setting. }, { "key": "Shift+KeyJ", // Key setting. - "command": [ - "cycle", - "secondary-sid" - ] // Command setting. + "command": ["cycle", "secondary-sid"], // Command setting. }, { "key": "ArrowRight", // Key setting. - "command": [ - "seek", - 5 - ] // Command setting. + "command": ["seek", 5], // Command setting. }, { "key": "ArrowLeft", // Key setting. - "command": [ - "seek", - -5 - ] // Command setting. + "command": ["seek", -5], // Command setting. }, { "key": "ArrowUp", // Key setting. - "command": [ - "seek", - 60 - ] // Command setting. + "command": ["seek", 60], // Command setting. }, { "key": "ArrowDown", // Key setting. - "command": [ - "seek", - -60 - ] // Command setting. + "command": ["seek", -60], // Command setting. }, { "key": "Shift+KeyH", // Key setting. - "command": [ - "sub-seek", - -1 - ] // Command setting. + "command": ["sub-seek", -1], // Command setting. }, { "key": "Shift+KeyL", // Key setting. - "command": [ - "sub-seek", - 1 - ] // Command setting. + "command": ["sub-seek", 1], // Command setting. }, { "key": "Shift+BracketRight", // Key setting. - "command": [ - "__sub-delay-next-line" - ] // Command setting. + "command": ["__sub-delay-next-line"], // Command setting. }, { "key": "Shift+BracketLeft", // Key setting. - "command": [ - "__sub-delay-prev-line" - ] // Command setting. + "command": ["__sub-delay-prev-line"], // Command setting. }, { "key": "Ctrl+Alt+KeyC", // Key setting. - "command": [ - "__youtube-picker-open" - ] // Command setting. + "command": ["__youtube-picker-open"], // Command setting. }, { "key": "Ctrl+Alt+KeyP", // Key setting. - "command": [ - "__playlist-browser-open" - ] // Command setting. + "command": ["__playlist-browser-open"], // Command setting. }, { "key": "Ctrl+Shift+KeyH", // Key setting. - "command": [ - "__replay-subtitle" - ] // Command setting. + "command": ["__replay-subtitle"], // Command setting. }, { "key": "Ctrl+Shift+KeyL", // Key setting. - "command": [ - "__play-next-subtitle" - ] // Command setting. + "command": ["__play-next-subtitle"], // Command setting. }, { "key": "KeyQ", // Key setting. - "command": [ - "quit" - ] // Command setting. + "command": ["quit"], // Command setting. }, { "key": "Ctrl+KeyW", // Key setting. - "command": [ - "quit" - ] // Command setting. - } + "command": ["quit"], // Command setting. + }, ], // Default and custom keybindings that are merged with built-in defaults. // ========================================== @@ -338,7 +291,7 @@ "secondarySub": { "secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available. "autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false - "defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover + "defaultMode": "hover", // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover }, // Dual subtitle track options. // ========================================== @@ -350,7 +303,7 @@ "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. - "replace": true // Replace the active subtitle file when sync completes. Values: true | false + "replace": true, // Replace the active subtitle file when sync completes. Values: true | false }, // Subsync engine and executable paths. // ========================================== @@ -358,7 +311,7 @@ // Initial vertical subtitle position from the bottom. // ========================================== "subtitlePosition": { - "yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. + "yPercent": 10, // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen. }, // Initial vertical subtitle position from the bottom. // ========================================== @@ -383,12 +336,13 @@ "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": "transparent" // Subtitle hover token background color setting. + "--subtitle-hover-token-background-color": "transparent", // 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 + "primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched 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. @@ -399,7 +353,7 @@ "N2": "#f5a97f", // N2 setting. "N3": "#f9e2af", // N3 setting. "N4": "#8bd5ca", // N4 setting. - "N5": "#8aadf4" // N5 setting. + "N5": "#8aadf4", // N5 setting. }, // Jlpt colors setting. "frequencyDictionary": { "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false @@ -408,13 +362,7 @@ "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded "matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. - "bandedColors": [ - "#ed8796", - "#f5a97f", - "#f9e2af", - "#8bd5ca", - "#8aadf4" - ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + "bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX). }, // Frequency dictionary setting. "secondary": { "css": { @@ -430,9 +378,9 @@ "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. + "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. // ========================================== @@ -457,8 +405,8 @@ "--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. + "--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. // ========================================== @@ -472,7 +420,7 @@ "model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider. "baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider. "systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests. - "requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests. + "requestTimeoutMs": 15000, // Timeout in milliseconds for shared AI provider requests. }, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing. // ========================================== @@ -490,11 +438,9 @@ "enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false "host": "127.0.0.1", // Bind host for local AnkiConnect proxy. "port": 8766, // Bind port for local AnkiConnect proxy. - "upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. + "upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy. }, // Proxy setting. - "tags": [ - "SubMiner" - ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. "deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. "fields": { "word": "Expression", // Card field for the mined word or expression text. @@ -502,12 +448,12 @@ "image": "Picture", // Card field that receives the captured screenshot or animated image. "sentence": "Sentence", // Card field that receives the source sentence text. "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). - "translation": "SelectionText" // Card field that receives the current selection or translated text. + "translation": "SelectionText", // Card field that receives the current selection or translated text. }, // Fields setting. "ai": { "enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false "model": "", // Optional model override for Anki AI translation/enrichment flows. - "systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows. + "systemPrompt": "", // Optional system prompt override for Anki AI translation/enrichment flows. }, // Ai setting. "media": { "generateAudio": true, // Generate sentence audio for mined cards. Values: true | false @@ -524,14 +470,14 @@ "syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. - "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. + "maxMediaDuration": 30, // Maximum allowed media clip duration in seconds. }, // Media setting. "knownWords": { "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "refreshMinutes": 1440, // Minutes between known-word cache refreshes. "addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface - "decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. + "decks": {}, // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. }, // Known words setting. "behavior": { "overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false @@ -539,24 +485,24 @@ "mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend "highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false "notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none - "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + "autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false }, // Behavior setting. "nPlusOne": { "enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false - "minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3). + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). }, // N plus one setting. "metadata": { - "pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). + "pattern": "[SubMiner] %f (%t)", // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp). }, // Metadata setting. "isLapis": { "enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false - "sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards. + "sentenceCardModel": "Lapis", // Note type name used by Lapis sentence cards. }, // Is lapis setting. "isKiku": { "enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled - "deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false - } // Is kiku setting. + "deleteDuplicateInAuto": true, // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false + }, // Is kiku setting. }, // Automatic Anki updates and media generation options. // ========================================== @@ -569,7 +515,7 @@ "apiKey": "", // Jimaku API key. Optional but recommended for higher rate limits. Get one for free at https://jimaku.cc. "apiKeyCommand": "", // Shell command that prints the Jimaku API key to stdout. Used instead of apiKey to avoid storing the key in plain text. "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none - "maxEntryResults": 10 // Maximum Jimaku search results returned. + "maxEntryResults": 10, // Maximum Jimaku search results returned. }, // Jimaku API configuration and defaults. // ========================================== @@ -578,10 +524,7 @@ // Hot-reload: primarySubLanguages applies to the next YouTube subtitle load. // ========================================== "youtube": { - "primarySubLanguages": [ - "ja", - "jpn" - ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + "primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority for managed subtitle auto-selection. }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== @@ -599,9 +542,9 @@ "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false - "voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false - } // Collapsible sections setting. - } // Character dictionary setting. + "voicedBy": false, // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false + }, // Collapsible sections setting. + }, // Character dictionary setting. }, // Anilist API credentials and update behavior. // ========================================== @@ -612,7 +555,7 @@ // In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings. // ========================================== "yomitan": { - "externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay + "externalProfilePath": "", // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay }, // Optional external Yomitan profile integration. // ========================================== @@ -634,7 +577,7 @@ "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false "subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path. "aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false - "aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible. + "aniskipButtonKey": "TAB", // mpv key used to trigger the AniSkip button while the skip marker is visible. }, // SubMiner-managed mpv launch and bundled plugin options. // ========================================== @@ -655,16 +598,8 @@ "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false - "directPlayContainers": [ - "mkv", - "mp4", - "webm", - "mov", - "flac", - "mp3", - "aac" - ], // Container allowlist for direct play decisions. - "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable. }, // Optional Jellyfin integration for auth, browsing, and playback launch. // ========================================== @@ -676,7 +611,7 @@ "enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false "presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal "updateIntervalMs": 3000, // Minimum interval between presence payload updates. - "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + "debounceMs": 750, // Debounce delay used to collapse bursty presence updates. }, // Optional Discord Rich Presence activity card updates for current playback/study session. // ========================================== @@ -701,13 +636,13 @@ "sessionsDays": 0, // Session retention window in days. Use 0 to keep all. "dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all. "monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all. - "vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable. + "vacuumIntervalDays": 0, // Minimum days between VACUUM runs. Use 0 to disable. }, // Retention setting. "lifetimeSummaries": { "global": true, // Maintain global lifetime stats rows. Values: true | false "anime": true, // Maintain per-anime lifetime stats rows. Values: true | false - "media": true // Maintain per-media lifetime stats rows. Values: true | false - } // Lifetime summaries setting. + "media": true, // Maintain per-media lifetime stats rows. Values: true | false + }, // Lifetime summaries setting. }, // Enable/disable immersion tracking. // ========================================== @@ -720,6 +655,6 @@ "markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry. "serverPort": 6969, // Port for the stats HTTP server. "autoStartServer": true, // Automatically start the stats server on launch. Values: true | false - "autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false - } // Local immersion stats dashboard served on localhost and available as an in-app overlay. + "autoOpenBrowser": false, // Automatically open the stats dashboard in a browser when the server starts. Values: true | false + }, // Local immersion stats dashboard served on localhost and available as an in-app overlay. } diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index a6531348..04ffe9d9 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -288,6 +288,48 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis assert.equal(privateState.runtime.proxyServer, null); }); +test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { + const integration = new AnkiIntegration( + { + isKiku: { + enabled: true, + fieldGrouping: 'manual', + }, + } as never, + {} as never, + {} as never, + ); + + let groupingTriggered = 0; + const internals = integration as unknown as { + cardCreationService: { + createSentenceCard: ( + sentence: string, + startTime: number, + endTime: number, + secondarySubText?: string, + ) => Promise; + }; + fieldGroupingService: { + triggerFieldGroupingForLastAddedCard: () => Promise; + }; + }; + internals.cardCreationService = { + createSentenceCard: async () => { + integration.trackDuplicateNoteIdsForNote(42, [7]); + return true; + }, + }; + internals.fieldGroupingService = { + triggerFieldGroupingForLastAddedCard: async () => { + groupingTriggered += 1; + }, + }; + + assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true); + assert.equal(groupingTriggered, 1); +}); + test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; const integration = new AnkiIntegration( @@ -316,7 +358,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); -test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { +test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( @@ -340,9 +382,9 @@ test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged Se assert.equal( merged.SentenceAudio, - '[sound:keep.mp3][sound:new.mp3]', + '[sound:new.mp3][sound:keep.mp3]', ); - assert.equal(merged.ExpressionAudio, merged.SentenceAudio); + assert.equal('ExpressionAudio' in merged, false); }); test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { @@ -374,7 +416,7 @@ test('FieldGroupingMergeCollaborator uses generated media fallback when source l assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); }); -test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and image values when merging into a new duplicate card', async () => { +test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( @@ -400,10 +442,19 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and false, ); - assert.equal(merged.Sentence, 'same sentence'); - assert.equal(merged.SentenceAudio, '[sound:same.mp3]'); - assert.equal(merged.Picture, ''); - assert.equal(merged.ExpressionAudio, merged.SentenceAudio); + assert.equal( + merged.Sentence, + 'same sentencesame sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:same.mp3][sound:same.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal('ExpressionAudio' in merged, false); }); test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index f477788b..ae6fc039 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -29,7 +29,7 @@ import { } from './types/anki'; import { AiConfig } from './types/integrations'; import { MpvClient } from './types/runtime'; -import { NPlusOneMatchMode } from './types/subtitle'; +import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { getConfiguredWordFieldCandidates, @@ -149,6 +149,7 @@ export class AnkiIntegration { private aiConfig: AiConfig; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; + private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private noteIdRedirects = new Map(); private trackedDuplicateNoteIds = new Map(); @@ -453,11 +454,13 @@ export class AnkiIntegration { mergeFieldValue: (existing, newValue, overwrite) => this.mergeFieldValue(existing, newValue, overwrite), generateAudioFilename: () => this.generateAudioFilename(), - generateAudio: () => this.generateAudio(), + generateAudio: (context) => this.generateAudio(context), generateImageFilename: () => this.generateImageFilename(), - generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds), + generateImage: (animatedLeadInSeconds, context) => + this.generateImage(animatedLeadInSeconds, context), formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), + consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), showNotification: (noteId, label) => this.showNotification(noteId, label), showOsdNotification: (message) => this.showOsdNotification(message), @@ -474,6 +477,7 @@ export class AnkiIntegration { client: { notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields), + addTags: (noteIds, tags) => this.client.addTags(noteIds, tags), deleteNotes: (noteIds) => this.client.deleteNotes(noteIds), }, getConfig: () => this.config, @@ -673,7 +677,55 @@ export class AnkiIntegration { return `${prefix}${highlightedText}${suffix}`; } - private async generateAudio(): Promise { + private consumeSubtitleMiningContext(): SubtitleMiningContext | null { + if (!this.consumeSubtitleMiningContextCallback) { + return null; + } + + try { + return this.consumeSubtitleMiningContextCallback(); + } catch (error) { + log.warn('Subtitle mining context callback failed:', (error as Error).message); + return null; + } + } + + private getSubtitleMediaRange(context?: SubtitleMiningContext): { + startTime: number; + endTime: number; + } { + if ( + context && + Number.isFinite(context.startTime) && + Number.isFinite(context.endTime) && + context.endTime > context.startTime + ) { + return { + startTime: context.startTime, + endTime: context.endTime, + }; + } + + if ( + Number.isFinite(this.mpvClient.currentSubStart) && + Number.isFinite(this.mpvClient.currentSubEnd) && + this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart + ) { + return { + startTime: this.mpvClient.currentSubStart, + endTime: this.mpvClient.currentSubEnd, + }; + } + + const currentTime = this.mpvClient.currentTimePos || 0; + const fallback = this.getFallbackDurationSeconds() / 2; + return { + startTime: currentTime - fallback, + endTime: currentTime + fallback, + }; + } + + private async generateAudio(context?: SubtitleMiningContext): Promise { const mpvClient = this.mpvClient; if (!mpvClient || !mpvClient.currentVideoPath) { return null; @@ -683,15 +735,7 @@ export class AnkiIntegration { if (!videoPath) { return null; } - let startTime = mpvClient.currentSubStart; - let endTime = mpvClient.currentSubEnd; - - if (startTime === undefined || endTime === undefined) { - const currentTime = mpvClient.currentTimePos || 0; - const fallback = this.getFallbackDurationSeconds() / 2; - startTime = currentTime - fallback; - endTime = currentTime + fallback; - } + const { startTime, endTime } = this.getSubtitleMediaRange(context); return this.mediaGenerator.generateAudio( videoPath, @@ -702,7 +746,10 @@ export class AnkiIntegration { ); } - private async generateImage(animatedLeadInSeconds = 0): Promise { + private async generateImage( + animatedLeadInSeconds = 0, + context?: SubtitleMiningContext, + ): Promise { if (!this.mpvClient || !this.mpvClient.currentVideoPath) { return null; } @@ -711,22 +758,16 @@ export class AnkiIntegration { if (!videoPath) { return null; } - const timestamp = this.mpvClient.currentTimePos || 0; + const mediaRange = this.getSubtitleMediaRange(context); + const timestamp = context + ? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2 + : this.mpvClient.currentTimePos || 0; if (this.config.media?.imageType === 'avif') { - let startTime = this.mpvClient.currentSubStart; - let endTime = this.mpvClient.currentSubEnd; - - if (startTime === undefined || endTime === undefined) { - const fallback = this.getFallbackDurationSeconds() / 2; - startTime = timestamp - fallback; - endTime = timestamp + fallback; - } - return this.mediaGenerator.generateAnimatedImage( videoPath, - startTime, - endTime, + mediaRange.startTime, + mediaRange.endTime, this.config.media?.audioPadding, { fps: this.config.media?.animatedFps, @@ -1064,18 +1105,48 @@ export class AnkiIntegration { endTime: number, secondarySubText?: string, ): Promise { - return this.cardCreationService.createSentenceCard( + const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys()); + const created = await this.cardCreationService.createSentenceCard( sentence, startTime, endTime, secondarySubText, ); + if ( + created && + this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( + trackedDuplicateNoteIdsBeforeCreate, + ) + ) { + try { + await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard(); + } catch (error) { + log.warn('Failed to trigger field grouping after sentence card creation:', error); + } + } + return created; } trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void { this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]); } + private shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( + trackedDuplicateNoteIdsBeforeCreate: Set, + ): boolean { + const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); + if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') { + return false; + } + + for (const noteId of this.trackedDuplicateNoteIds.keys()) { + if (!trackedDuplicateNoteIdsBeforeCreate.has(noteId)) { + return true; + } + } + return false; + } + private async findDuplicateNote( expression: string, excludeNoteId: number, @@ -1287,6 +1358,10 @@ export class AnkiIntegration { this.knownWordCacheUpdatedCallback = callback; } + setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void { + this.consumeSubtitleMiningContextCallback = callback; + } + resolveCurrentNoteId(noteId: number): number { let resolved = noteId; const seen = new Set(); diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts index 18deec37..1a1494e3 100644 --- a/src/anki-integration/field-grouping-merge.test.ts +++ b/src/anki-integration/field-grouping-merge.test.ts @@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record): FieldGrouping }; } -test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => { +test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => { const { collaborator } = createCollaborator({ config: { fields: { image: 'Illustration', sentence: 'SentenceText', - audio: 'ExpressionAudio', + audio: 'CustomWordAudio', miscInfo: 'ExtraInfo', }, }, @@ -97,33 +97,84 @@ test('getGroupableFieldNames includes configured fields without duplicating Expr ]); }); -test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => { - const { collaborator } = createCollaborator({ - config: { - fields: { - audio: 'CustomAudio', - }, - }, - }); +test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => { + const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( - 1, - 2, - makeNote(1, { - SentenceAudio: '[sound:keep.mp3]', - CustomAudio: '[sound:stale.mp3]', + 300, + 200, + makeNote(300, { + Sentence: 'original sentence', + SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]', + Picture: '', + MiscInfo: 'original misc', + ExpressionAudio: '[sound:word.mp3]', }), - makeNote(2, { + makeNote(200, { + Sentence: 'new sentence', SentenceAudio: '[sound:new.mp3]', + Picture: '', + MiscInfo: 'new misc', }), false, ); assert.equal( - merged.SentenceAudio, - '[sound:keep.mp3][sound:new.mp3]', + merged.Sentence, + 'original sentencenew sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:original-a.mp3] [sound:original-b.mp3][sound:new.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal( + merged.MiscInfo, + 'original miscnew misc', + ); + assert.equal('ExpressionAudio' in merged, false); +}); + +test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => { + const { collaborator } = createCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 200, + 300, + makeNote(200, { + Sentence: 'new sentence', + SentenceAudio: '[sound:new.mp3]', + Picture: '', + MiscInfo: 'new misc', + }), + makeNote(300, { + Sentence: 'original sentence', + SentenceAudio: '[sound:original.mp3]', + Picture: '', + MiscInfo: 'original misc', + }), + false, + ); + + assert.equal( + merged.Sentence, + 'original sentencenew sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:original.mp3][sound:new.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal( + merged.MiscInfo, + 'original miscnew misc', ); - assert.equal(merged.CustomAudio, merged.SentenceAudio); }); test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => { @@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty assert.equal( merged.Sentence, - 'keep sentencesource sentence', + 'source sentencekeep sentence', ); assert.equal(merged.SentenceAudio, '[sound:source.mp3]'); assert.equal(warnings.length, 4); @@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen assert.equal(withMedia.Picture, ''); assert.equal(withMedia.MiscInfo, 'generated misc'); }); + +test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => { + const { collaborator } = createCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + SentenceFurigana: 'original furigana', + }), + makeNote(200, { + SentenceFurigana: '', + }), + false, + ); + + assert.equal(merged.SentenceFurigana, ''); +}); diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index 5ddccedb..e2883dfa 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator { fields.push('Picture'); if (config.fields?.image) fields.push(config.fields?.image); if (config.fields?.sentence) fields.push(config.fields?.sentence); - if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') { - fields.push(config.fields?.audio); - } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceAudioField = sentenceCardConfig.audioField; if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); @@ -94,12 +91,6 @@ export class FieldGroupingMergeCollaborator { } } - if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) { - sourceFields['SentenceFurigana'] = sourceFields['Sentence']; - } - if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { - sourceFields['Sentence'] = sourceFields['SentenceFurigana']; - } if (!sourceFields[configuredWordField] && sourceFields['Expression']) { sourceFields[configuredWordField] = sourceFields['Expression']; } @@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator { if (!sourceFields['Word'] && sourceFields[configuredWordField]) { sourceFields['Word'] = sourceFields[configuredWordField]; } - if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) { - sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; - } - if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) { - sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio']; - } - if ( config.fields?.sentence && !sourceFields[config.fields?.sentence] && @@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator { const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); if (!existingValue.trim() && !newValue.trim()) continue; + if (keepFieldNormalized === 'sentencefurigana') { + mergedFields[keepFieldName] = + existingValue.trim() && newValue.trim() + ? this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ) + : ''; + continue; + } + if (isStrictField) { mergedFields[keepFieldName] = this.applyFieldGrouping( existingValue, @@ -191,29 +189,6 @@ export class FieldGroupingMergeCollaborator { } } - const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); - const resolvedSentenceAudioField = this.deps.resolveFieldName( - keepFieldNames, - sentenceCardConfig.audioField || 'SentenceAudio', - ); - const resolvedExpressionAudioField = this.deps.resolveFieldName( - keepFieldNames, - config.fields?.audio || 'ExpressionAudio', - ); - if ( - resolvedSentenceAudioField && - resolvedExpressionAudioField && - resolvedExpressionAudioField !== resolvedSentenceAudioField - ) { - const mergedSentenceAudioValue = - mergedFields[resolvedSentenceAudioField] || - keepNoteInfo.fields[resolvedSentenceAudioField]?.value || - ''; - if (mergedSentenceAudioValue.trim()) { - mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue; - } - } - return mergedFields; } @@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator { } private extractUngroupedValue(value: string): string { - const groupedSpanRegex = /[\s\S]*?<\/span>/gi; - const ungrouped = value.replace(groupedSpanRegex, '').trim(); + const ungrouped = this.extractUngroupedRemainder(value); if (ungrouped) return ungrouped; return value.trim(); } - private extractLastSoundTag(value: string): string { - const matches = value.match(/\[sound:[^\]]+\]/g); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; - } - - private extractLastImageTag(value: string): string { - const matches = value.match(/]*>/gi); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; + private extractUngroupedRemainder(value: string): string { + const groupedSpanRegex = /]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi; + return value.replace(groupedSpanRegex, '').trim(); } private extractImageTags(value: string): string[] { @@ -274,7 +241,7 @@ export class FieldGroupingMergeCollaborator { } } - const spanRegex = /]*>([\s\S]*?)<\/span>/gi; + const spanRegex = /]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi; let match; while ((match = spanRegex.exec(value)) !== null) { const groupId = Number(match[1]); @@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator { fieldName: string, ): { groupId: number; content: string }[] { const entries = this.extractSpanEntries(value, fieldName); - if (entries.length === 0) { - const ungrouped = this.normalizeStrictGroupedValue( - this.extractUngroupedValue(value), - fieldName, - ); - if (ungrouped) { - entries.push({ groupId: fallbackGroupId, content: ungrouped }); - } + const ungroupedSource = + entries.length > 0 + ? this.extractUngroupedRemainder(value) + : this.extractUngroupedValue(value); + const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName); + if (ungrouped) { + entries.push({ groupId: fallbackGroupId, content: ungrouped }); } - const unique: { groupId: number; content: string }[] = []; - const seen = new Set(); - for (const entry of entries) { - const key = entry.content; - if (seen.has(key)) continue; - seen.add(key); - unique.push(entry); - } - return unique; + return entries; } private parsePictureEntries( @@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator { if (!ungrouped) return ''; const normalizedField = fieldName.toLowerCase(); - if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { - const lastSoundTag = this.extractLastSoundTag(ungrouped); - if (!lastSoundTag) { - this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); - } - return lastSoundTag || ungrouped; - } - - if (normalizedField === 'picture') { - const lastImageTag = this.extractLastImageTag(ungrouped); - if (!lastImageTag) { - this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag'); - } - return lastImageTag || ungrouped; + if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); } return ungrouped; } - private getPictureDedupKey(tag: string): string { - return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim(); - } - private getStrictSpanGroupingFields(): Set { const strictFields = new Set(this.strictGroupingFieldDefaults); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); @@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator { return this.getStrictSpanGroupingFields().has(normalized); } + private isPictureField(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + const configuredImageField = this.deps.getConfig().fields?.image?.toLowerCase(); + return normalized === 'picture' || normalized === configuredImageField; + } + + private sortEntriesByGroupIdDescending(entries: T[]): T[] { + return [...entries].sort((a, b) => b.groupId - a.groupId); + } + private applyFieldGrouping( existingValue: string, newValue: string, @@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator { fieldName: string, ): string { if (this.shouldUseStrictSpanGrouping(fieldName)) { - if (fieldName.toLowerCase() === 'picture') { + if (this.isPictureField(fieldName)) { const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); if (keepEntries.length === 0 && sourceEntries.length === 0) { return existingValue || newValue; } - const mergedTags = keepEntries.map((entry) => - this.ensureImageGroupId(entry.tag, entry.groupId), - ); - const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag))); - for (const entry of sourceEntries) { - const normalized = this.ensureImageGroupId(entry.tag, entry.groupId); - const dedupKey = this.getPictureDedupKey(normalized); - if (seen.has(dedupKey)) continue; - seen.add(dedupKey); - mergedTags.push(normalized); - } - return mergedTags.join(''); + return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]) + .map((entry) => this.ensureImageGroupId(entry.tag, entry.groupId)) + .join(''); } const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); @@ -423,19 +366,7 @@ export class FieldGroupingMergeCollaborator { if (keepEntries.length === 0 && sourceEntries.length === 0) { return existingValue || newValue; } - if (sourceEntries.length === 0) { - return keepEntries - .map((entry) => `${entry.content}`) - .join(''); - } - const merged = [...keepEntries]; - const seen = new Set(keepEntries.map((entry) => entry.content)); - for (const entry of sourceEntries) { - const key = entry.content; - if (seen.has(key)) continue; - seen.add(key); - merged.push(entry); - } + const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]); if (merged.length === 0) return existingValue; return merged .map((entry) => `${entry.content}`) diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 361ae041..be21a17a 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an type NoteInfo = { noteId: number; fields: Record; + tags?: string[]; }; type ManualChoice = { @@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: { function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; const deleted: number[][] = []; + const addedTags: Array<{ noteIds: number[]; tags: string[] }> = []; const statuses: string[] = []; const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = []; const mergeCalls: Array<{ @@ -49,6 +51,9 @@ function createWorkflowHarness() { updateNoteFields: async (noteId: number, fields: Record) => { updates.push({ noteId, fields }); }, + addTags: async (noteIds: number[], tags: string[]) => { + addedTags.push({ noteIds, tags }); + }, deleteNotes: async (noteIds: number[]) => { deleted.push(noteIds); }, @@ -117,6 +122,7 @@ function createWorkflowHarness() { workflow: new FieldGroupingWorkflow(deps), updates, deleted, + addedTags, rememberedMerges, statuses, mergeCalls, @@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b assert.equal(harness.statuses.length, 1); }); +test('FieldGroupingWorkflow merges source tags into target and filters special source tags', async () => { + const harness = createWorkflowHarness(); + harness.deps.client.notesInfo = async (noteIds: number[]) => + noteIds.map((noteId) => ({ + noteId, + fields: { + Expression: { value: `word-${noteId}` }, + Sentence: { value: `line-${noteId}` }, + }, + tags: + noteId === 1 ? ['kinkoi', 'marked'] : ['SubMiner', 'marked', 'leech', 'potential_leech'], + })); + + await harness.workflow.handleAuto(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + tags: ['SubMiner', 'marked', 'leech', 'potential_leech'], + }); + + assert.deepEqual(harness.addedTags, [{ noteIds: [1], tags: ['SubMiner'] }]); +}); + test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => { const harness = createWorkflowHarness(); diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 3369bd54..0f42656f 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; export interface FieldGroupingWorkflowNoteInfo { noteId: number; fields: Record; + tags?: string[]; } export interface FieldGroupingWorkflowDeps { client: { notesInfo(noteIds: number[]): Promise; updateNoteFields(noteId: number, fields: Record): Promise; + addTags(noteIds: number[], tags: string[]): Promise; deleteNotes(noteIds: number[]): Promise; }; getConfig: () => { @@ -156,6 +158,11 @@ export class FieldGroupingWorkflow { await this.deps.addConfiguredTagsToNote(keepNoteId); } + const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo); + if (tagsToAdd.length > 0) { + await this.deps.client.addTags([keepNoteId], tagsToAdd); + } + if (deleteDuplicate) { await this.deps.client.deleteNotes([deleteNoteId]); this.deps.removeTrackedNoteId(deleteNoteId); @@ -200,6 +207,24 @@ export class FieldGroupingWorkflow { return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()); } + private getMergeTagsToAdd( + keepNoteInfo: FieldGroupingWorkflowNoteInfo, + deleteNoteInfo: FieldGroupingWorkflowNoteInfo, + ): string[] { + const targetTags = new Set((keepNoteInfo.tags ?? []).map((tag) => tag.trim()).filter(Boolean)); + const unwantedSourceTags = new Set(['leech', 'marked', 'potential_leech']); + const tagsToAdd: string[] = []; + + for (const rawTag of deleteNoteInfo.tags ?? []) { + const tag = rawTag.trim(); + if (!tag || targetTags.has(tag) || unwantedSourceTags.has(tag)) continue; + targetTags.add(tag); + tagsToAdd.push(tag); + } + + return tagsToAdd; + } + private async resolveFieldGroupingCallback(): Promise< | ((data: { original: KikuDuplicateCardInfo; diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 49e259f0..218236c0 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -5,6 +5,7 @@ import { type NoteUpdateWorkflowDeps, type NoteUpdateWorkflowNoteInfo, } from './note-update-workflow'; +import type { SubtitleMiningContext } from '../types/subtitle'; function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; @@ -203,3 +204,72 @@ test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word assert.equal(receivedLeadInSeconds, 1.25); }); + +test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => { + const harness = createWorkflowHarness(); + const sidebarContext = { + source: 'subtitle-sidebar' as const, + text: 'sidebar previous line', + startTime: 10, + endTime: 12, + capturedAtMs: 123, + }; + let audioContext: unknown = null; + let imageContext: unknown = null; + let miscInfoStartTime: number | undefined; + + harness.deps.client.notesInfo = async () => + [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: 'sidebar previous line' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + MiscInfo: { value: '' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + harness.deps.getConfig = () => ({ + fields: { + sentence: 'Sentence', + image: 'Picture', + miscInfo: 'MiscInfo', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'avif', + }, + behavior: {}, + }); + harness.deps.getCurrentSubtitleText = () => 'current primary line'; + harness.deps.getCurrentSubtitleStart = () => 20; + harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio'; + harness.deps.generateAudio = async (context?: SubtitleMiningContext) => { + audioContext = context ?? null; + return Buffer.from('audio'); + }; + harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => { + imageContext = context ?? null; + return Buffer.from('image'); + }; + harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => { + miscInfoStartTime = startTimeSeconds; + return `start:${startTimeSeconds}`; + }; + ( + harness.deps as NoteUpdateWorkflowDeps & { + consumeSubtitleMiningContext: () => typeof sidebarContext | null; + } + ).consumeSubtitleMiningContext = () => sidebarContext; + + await harness.workflow.execute(42); + + assert.equal(harness.updates.length, 1); + assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line'); + assert.deepEqual(audioContext, sidebarContext); + assert.deepEqual(imageContext, sidebarContext); + assert.equal(miscInfoStartTime, 10); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 26613ffa..c0511dc3 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -1,5 +1,6 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; +import type { SubtitleMiningContext } from '../types/subtitle'; export interface NoteUpdateWorkflowNoteInfo { noteId: number; @@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps { getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; generateAudioFilename: () => string; - generateAudio: () => Promise; + generateAudio: (context?: SubtitleMiningContext) => Promise; generateImageFilename: () => string; - generateImage: (animatedLeadInSeconds?: number) => Promise; + generateImage: ( + animatedLeadInSeconds?: number, + context?: SubtitleMiningContext, + ) => Promise; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + consumeSubtitleMiningContext?: () => SubtitleMiningContext | null; addConfiguredTagsToNote: (noteId: number) => Promise; showNotification: (noteId: number, label: string | number) => Promise; showOsdNotification: (message: string) => void; @@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps { logError: (message: string, ...args: unknown[]) => void; } +function normalizeSubtitleContextText(text: string): string { + return text + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function hasUsableSubtitleContextTiming(context: SubtitleMiningContext): boolean { + return ( + Number.isFinite(context.startTime) && + Number.isFinite(context.endTime) && + context.endTime > context.startTime + ); +} + +function subtitleContextMatchesSentence(contextText: string, noteSentence: string): boolean { + const normalizedContext = normalizeSubtitleContextText(contextText); + const normalizedSentence = normalizeSubtitleContextText(noteSentence); + if (!normalizedContext || !normalizedSentence) { + return false; + } + return ( + normalizedContext === normalizedSentence || + normalizedContext.includes(normalizedSentence) || + normalizedSentence.includes(normalizedContext) + ); +} + export class NoteUpdateWorkflow { constructor(private readonly deps: NoteUpdateWorkflowDeps) {} + private consumeMatchingSubtitleMiningContext( + fields: Record, + sentenceField: string, + configuredSentenceField?: string, + ): SubtitleMiningContext | null { + const context = this.deps.consumeSubtitleMiningContext?.() ?? null; + if (!context || !hasUsableSubtitleContextTiming(context)) { + return null; + } + + const candidateFields = [ + sentenceField, + configuredSentenceField, + DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence, + ]; + const noteSentence = candidateFields + .map((fieldName) => (fieldName ? fields[fieldName.toLowerCase()] : undefined)) + .find((value): value is string => typeof value === 'string' && value.trim().length > 0); + + if (!noteSentence || subtitleContextMatchesSentence(context.text, noteSentence)) { + return context; + } + return null; + } + async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise { this.deps.beginUpdateProgress('Updating card'); try { @@ -121,8 +179,13 @@ export class NoteUpdateWorkflow { let updatePerformed = false; let miscInfoFilename: string | null = null; const sentenceField = sentenceCardConfig.sentenceField; + const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext( + fields, + sentenceField, + config.fields?.sentence, + ); - const currentSubtitleText = this.deps.getCurrentSubtitleText(); + const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText(); if (sentenceField && currentSubtitleText) { const processedSentence = this.deps.processSentence(currentSubtitleText, fields); updatedFields[sentenceField] = processedSentence; @@ -132,7 +195,7 @@ export class NoteUpdateWorkflow { if (config.media?.generateAudio) { try { const audioFilename = this.deps.generateAudioFilename(); - const audioBuffer = await this.deps.generateAudio(); + const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined); if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); @@ -158,7 +221,10 @@ export class NoteUpdateWorkflow { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.deps.generateImageFilename(); - const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds); + const imageBuffer = await this.deps.generateImage( + animatedLeadInSeconds, + subtitleMiningContext ?? undefined, + ); if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); @@ -189,7 +255,7 @@ export class NoteUpdateWorkflow { if (config.fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || '', - this.deps.getCurrentSubtitleStart(), + subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(), ); const miscInfoField = this.deps.resolveConfiguredFieldName( noteInfo, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index a7b5f698..8bf7de34 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -104,6 +104,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true); + assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true); assert.equal(config.subtitleSidebar.enabled, true); assert.equal(config.subtitleSidebar.pauseVideoOnHover, true); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); @@ -545,6 +546,44 @@ test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid val ); }); +test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "primaryVisibleOnYomitanPopup": false + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "primaryVisibleOnYomitanPopup": "yes" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, + DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'), + ); +}); + test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index b3392fbc..1c9ffe36 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick { + const valid = createResolveContext({ + subtitleStyle: { + primaryVisibleOnYomitanPopup: false, + }, + }); + applySubtitleDomainConfig(valid.context); + assert.equal(valid.context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, false); + + const { context, warnings } = createResolveContext({ + subtitleStyle: { + primaryVisibleOnYomitanPopup: 'invalid' as unknown as boolean, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, true); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup' && + warning.message === 'Expected boolean.', + ), + ); +}); + test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 720eb19b..e08c8f3a 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -15,6 +15,8 @@ test('settings registry splits viewing into appearance and behavior categories', assert.equal(field('subtitleStyle.fontSize').category, 'appearance'); assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior'); assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior'); + assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior'); + assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior'); assert.equal(field('secondarySub.defaultMode').category, 'behavior'); assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position'); assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode'); @@ -28,7 +30,14 @@ test('settings registry splits viewing into appearance and behavior categories', assert.equal(field('mpv.profile').section, 'mpv Playback'); assert.ok( fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') < - fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), + fields.findIndex( + (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', + ), + ); + assert.ok( + fields.findIndex( + (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', + ) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), ); }); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index a819bb4c..5e679813 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -168,6 +168,7 @@ const PATH_ORDER = new Map( 'subtitleStyle.hoverTokenBackgroundColor', 'subtitleStyle.css', 'subtitleStyle.primaryDefaultMode', + 'subtitleStyle.primaryVisibleOnYomitanPopup', 'subtitleStyle.secondary.fontColor', 'subtitleStyle.secondary.backgroundColor', 'subtitleStyle.secondary.css', @@ -218,6 +219,7 @@ const LABEL_OVERRIDES: Record = { 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', + 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode', 'subtitleStyle.css': 'CSS Declarations', @@ -251,6 +253,8 @@ const DESCRIPTION_OVERRIDES: Record = { 'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.', 'subtitleSidebar.css': 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', + 'subtitleStyle.primaryVisibleOnYomitanPopup': + 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.', 'websocket.enabled': 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', 'discordPresence.updateIntervalMs': @@ -359,7 +363,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('subtitleStyle.secondary.')) { return { category: 'appearance', section: 'Secondary Subtitle Appearance' }; } - if (path === 'subtitleStyle.primaryDefaultMode') { + if ( + path === 'subtitleStyle.primaryDefaultMode' || + path === 'subtitleStyle.primaryVisibleOnYomitanPopup' + ) { return { category: 'behavior', section: 'Subtitle Behavior' }; } if (path.startsWith('subtitleStyle.')) { @@ -603,6 +610,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean { } function fieldTypeRank(field: ConfigSettingsField): number { + if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2; if (field.control !== 'boolean') return 2; return isFeatureToggle(field) ? 0 : 1; } diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index 0572d798..0b5c4614 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay assert.equal(visible, false); assert.deepEqual(visibilityTransitions, [true, false]); }); + +async function settleWithinMicrotasks( + promise: Promise, + attempts = 10, +): Promise { + let settled = false; + let settledValue: T | undefined; + void promise.then((value) => { + settled = true; + settledValue = value; + }); + + for (let i = 0; i < attempts; i += 1) { + await Promise.resolve(); + if (settled) { + return settledValue as T; + } + } + return 'timeout'; +} + +test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => { + let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; + const sends: Array<{ + channel: string; + payload: unknown; + restoreOnModalClose?: string; + preferModalWindow?: boolean; + }> = []; + const waitCalls: Array<{ modal: string; timeoutMs: number }> = []; + const warnings: string[] = []; + const closed: string[] = []; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout; + + try { + const runtime = createFieldGroupingOverlayRuntime<'kiku'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => true, + setVisibleOverlayVisible: () => {}, + getResolver: () => resolver, + setResolver: (nextResolver) => { + resolver = nextResolver; + }, + getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), + sendToVisibleOverlay: (channel, payload, runtimeOptions) => { + sends.push({ + channel, + payload, + restoreOnModalClose: runtimeOptions?.restoreOnModalClose, + preferModalWindow: runtimeOptions?.preferModalWindow, + }); + return true; + }, + waitForModalOpen: async (modal, timeoutMs) => { + waitCalls.push({ modal, timeoutMs }); + return false; + }, + handleOverlayModalClosed: (modal) => { + closed.push(modal); + }, + logWarn: (message) => { + warnings.push(message); + }, + }); + + const request = { + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }; + const pendingChoice = runtime.createFieldGroupingCallback()(request); + const result = await settleWithinMicrotasks(pendingChoice); + + assert.notEqual(result, 'timeout'); + assert.deepEqual(result, { + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + assert.equal(resolver, null); + assert.deepEqual( + sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({ + channel, + restoreOnModalClose, + preferModalWindow, + })), + [ + { + channel: 'kiku:field-grouping-request', + restoreOnModalClose: 'kiku', + preferModalWindow: true, + }, + { + channel: 'kiku:field-grouping-request', + restoreOnModalClose: 'kiku', + preferModalWindow: true, + }, + ], + ); + assert.deepEqual(waitCalls, [ + { modal: 'kiku', timeoutMs: 1500 }, + { modal: 'kiku', timeoutMs: 1500 }, + ]); + assert.deepEqual(warnings, [ + 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + ]); + assert.deepEqual(closed, ['kiku']); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); diff --git a/src/core/services/field-grouping-overlay.ts b/src/core/services/field-grouping-overlay.ts index 60b9b128..2e17aed6 100644 --- a/src/core/services/field-grouping-overlay.ts +++ b/src/core/services/field-grouping-overlay.ts @@ -8,6 +8,10 @@ interface WindowLike { }; } +const KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS = 1500; +const KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING = + 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.'; + export interface FieldGroupingOverlayRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisible: () => boolean; @@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions { getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; getRestoreVisibleOverlayOnModalClose: () => Set; + waitForModalOpen?: (modal: T, timeoutMs: number) => Promise; + handleOverlayModalClosed?: (modal: T) => void; + logWarn?: (message: string) => void; sendToVisibleOverlay?: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; } @@ -28,7 +35,7 @@ export function createFieldGroupingOverlayRuntime( sendToVisibleOverlay: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, @@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime( const sendToVisibleOverlay = ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ): boolean => { if (options.sendToVisibleOverlay) { const wasVisible = options.getVisibleOverlayVisible(); @@ -58,6 +65,43 @@ export function createFieldGroupingOverlayRuntime( }); }; + const sendKikuFieldGroupingRequest = async ( + data: KikuFieldGroupingRequestData, + ): Promise => { + const kikuModal = 'kiku' as T; + const sendOpen = (): boolean => + sendToVisibleOverlay('kiku:field-grouping-request', data, { + restoreOnModalClose: kikuModal, + preferModalWindow: true, + }); + + if (!options.waitForModalOpen) { + return sendOpen(); + } + + if (!sendOpen()) { + return false; + } + if (await options.waitForModalOpen(kikuModal, KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS)) { + return true; + } + + options.logWarn?.(KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING); + if (!sendOpen()) { + options.handleOverlayModalClosed?.(kikuModal); + return false; + } + + const opened = await options.waitForModalOpen( + kikuModal, + KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS, + ); + if (!opened) { + options.handleOverlayModalClosed?.(kikuModal); + } + return opened; + }; + const createFieldGroupingCallback = (): (( data: KikuFieldGroupingRequestData, ) => Promise) => { @@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime( getResolver: options.getResolver, setResolver: options.setResolver, sendToVisibleOverlay, + sendKikuFieldGroupingRequest, }); }; diff --git a/src/core/services/field-grouping.ts b/src/core/services/field-grouping.ts index 9ed3cc34..6585bcd5 100644 --- a/src/core/services/field-grouping.ts +++ b/src/core/services/field-grouping.ts @@ -5,7 +5,7 @@ export function createFieldGroupingCallback(options: { setVisibleOverlayVisible: (visible: boolean) => void; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; - sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; + sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise; }): (data: KikuFieldGroupingRequestData) => Promise { return async (data: KikuFieldGroupingRequestData): Promise => { return new Promise((resolve) => { @@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: { const previousVisibleOverlay = options.getVisibleOverlayVisible(); let settled = false; + let timeout: ReturnType | null = null; const finish = (choice: KikuFieldGroupingChoice): void => { if (settled) return; settled = true; + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } if (options.getResolver() === finish) { options.setResolver(null); } @@ -36,25 +41,38 @@ export function createFieldGroupingCallback(options: { }; options.setResolver(finish); - if (!options.sendRequestToVisibleOverlay(data)) { - finish({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); - return; - } - setTimeout(() => { - if (!settled) { + void Promise.resolve(options.sendRequestToVisibleOverlay(data)).then( + (sent) => { + if (settled) return; + if (!sent) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + timeout = setTimeout(() => { + if (!settled) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + } + }, 90000); + }, + () => { finish({ keepNoteId: 0, deleteNoteId: 0, deleteDuplicate: true, cancelled: true, }); - } - }, 90000); + }, + ); }); }; } diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index f9429a63..fd09cd24 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -630,6 +630,43 @@ test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion assert.deepEqual(calls, ['lookup']); }); +test('registerIpcHandlers forwards valid subtitle sidebar mining context', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const contexts: unknown[] = []; + const deps = createRegisterIpcDeps() as IpcServiceDeps & { + recordSubtitleMiningContext: (context: unknown | null) => void; + }; + deps.recordSubtitleMiningContext = (context) => { + contexts.push(context); + }; + + registerIpcHandlers(deps, registrar); + + const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup); + assert.equal(typeof handler, 'function'); + + handler?.( + {}, + { + source: 'subtitle-sidebar', + text: 'sidebar previous line', + startTime: 10, + endTime: 12, + capturedAtMs: 123, + }, + ); + + assert.deepEqual(contexts, [ + { + source: 'subtitle-sidebar', + text: 'sidebar previous line', + startTime: 10, + endTime: 12, + capturedAtMs: 123, + }, + ]); +}); + test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers(createRegisterIpcDeps(), registrar); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ed66f003..9f03ef2c 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -9,6 +9,7 @@ import type { ResolvedControllerConfig, RuntimeOptionId, RuntimeOptionValue, + SubtitleMiningContext, SubtitleSidebarSnapshot, SubtitlePosition, SubsyncManualRunRequest, @@ -95,6 +96,7 @@ export interface IpcServiceDeps { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; + recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; setCharacterDictionarySelection?: ( mediaId: number, @@ -175,6 +177,43 @@ interface IpcMainRegistrar { handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; } +function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const record = payload as Record; + const source = record.source; + const text = record.text; + const startTime = record.startTime; + const endTime = record.endTime; + const capturedAtMs = record.capturedAtMs; + + if ( + source !== 'subtitle-sidebar' || + typeof text !== 'string' || + text.trim().length === 0 || + typeof startTime !== 'number' || + typeof endTime !== 'number' || + !Number.isFinite(startTime) || + !Number.isFinite(endTime) || + endTime <= startTime + ) { + return null; + } + + const parsed: SubtitleMiningContext = { + source: 'subtitle-sidebar', + text, + startTime, + endTime, + }; + if (typeof capturedAtMs === 'number' && Number.isFinite(capturedAtMs)) { + parsed.capturedAtMs = capturedAtMs; + } + return parsed; +} + export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; @@ -230,6 +269,7 @@ export interface IpcDepsRuntimeOptions { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; + recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; setCharacterDictionarySelection?: ( mediaId: number, @@ -257,6 +297,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService onOverlayModalOpened: options.onOverlayModalOpened, onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, openYomitanSettings: options.openYomitanSettings, + recordSubtitleMiningContext: options.recordSubtitleMiningContext, quitApp: options.quitApp, toggleDevTools: () => { const mainWindow = options.getMainWindow(); @@ -423,7 +464,8 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.openYomitanSettings(); }); - ipc.on(IPC_CHANNELS.command.recordYomitanLookup, () => { + ipc.on(IPC_CHANNELS.command.recordYomitanLookup, (_event: unknown, payload: unknown) => { + deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload)); deps.immersionTracker?.recordYomitanLookup(); }); diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts index 8f26db88..fd977aa0 100644 --- a/src/core/services/overlay-bridge.ts +++ b/src/core/services/overlay-bridge.ts @@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime(options: { sendToVisibleOverlay: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; + sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise; }): (data: KikuFieldGroupingRequestData) => Promise { return createFieldGroupingCallback({ getVisibleOverlayVisible: options.getVisibleOverlayVisible, @@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime(options: { getResolver: options.getResolver, setResolver: options.setResolver, sendRequestToVisibleOverlay: (data) => - options.sendToVisibleOverlay('kiku:field-grouping-request', data, { - restoreOnModalClose: 'kiku' as T, - }), + options.sendKikuFieldGroupingRequest + ? options.sendKikuFieldGroupingRequest(data) + : options.sendToVisibleOverlay('kiku:field-grouping-request', data, { + restoreOnModalClose: 'kiku' as T, + }), }); } diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index aee66806..98bb8398 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -25,6 +25,7 @@ interface YomitanTokenInput { surface: string; reading?: string; headword?: string; + frequencyRank?: number; isNameMatch?: boolean; wordClasses?: string[]; } @@ -57,6 +58,7 @@ function makeDepsFromYomitanTokens( startPos, endPos, isNameMatch: token.isNameMatch ?? false, + frequencyRank: token.frequencyRank, wordClasses: token.wordClasses, }; }); @@ -4279,6 +4281,64 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin assert.equal(result.tokens?.[0]?.frequencyRank, 5468); }); +test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds', async () => { + const result = await tokenizeSubtitle( + '目の前', + makeDepsFromYomitanTokens( + [{ surface: '目の前', reading: 'めのまえ', headword: '目の前', frequencyRank: 581 }], + { + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => [ + { + headword: '目', + surface: '目', + reading: 'メ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'の', + surface: 'の', + reading: 'ノ', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '連体化', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: '前', + surface: '前', + reading: 'マエ', + startPos: 2, + endPos: 3, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '副詞可能', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + }, + ), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, '目の前'); + assert.equal(result.tokens?.[0]?.pos1, '名詞|助詞'); + assert.equal(result.tokens?.[0]?.frequencyRank, 581); +}); + test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => { const result = await tokenizeSubtitle( '第二走者', diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 6098b881..3189e98f 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet exclusions.has(part)); + + return parts.every((part) => exclusions.has(part)); } function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet { @@ -227,6 +226,10 @@ function isFrequencyExcludedByPos( return true; } + if (isKanaOnlyMixedFunctionContentToken(token, pos1Exclusions)) { + return true; + } + const normalizedPos1 = normalizePos1Tag(token.pos1); const hasPos1 = normalizedPos1.length > 0; const normalizedPos2 = normalizePos2Tag(token.pos2); @@ -564,6 +567,35 @@ function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean { return chars.length === 1 && isKanaChar(chars[0]!); } +function isKanaOnlyText(text: string | undefined): boolean { + if (typeof text !== 'string') { + return false; + } + + const normalized = text.trim(); + if (!normalized) { + return false; + } + + return [...normalized].every(isKanaChar); +} + +function isKanaOnlyMixedFunctionContentToken( + token: MergedToken, + pos1Exclusions: ReadonlySet, +): boolean { + if (!isKanaOnlyText(token.surface)) { + return false; + } + + const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1)); + return ( + pos1Parts.length >= 2 && + pos1Parts.some((part) => pos1Exclusions.has(part)) && + pos1Parts.some((part) => !pos1Exclusions.has(part)) + ); +} + function isJlptEligibleToken(token: MergedToken): boolean { if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) { return false; diff --git a/src/main.ts b/src/main.ts index 3d02a9c0..85505926 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import type { SecondarySubMode, SubtitleCue, SubtitleData, + SubtitleMiningContext, SubtitlePosition, UpdateChannel, WindowGeometry, @@ -730,8 +731,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug') const texthookerService = new Texthooker(() => { const config = getResolvedConfig(); const characterDictionaryEnabled = - config.subtitleStyle.nameMatchEnabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled(); + config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownWordColoringEnabled = getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled, @@ -908,6 +908,7 @@ const { appState, appLifecycleApp, } = bootServices; +let pendingSubtitleMiningContext: SubtitleMiningContext | null = null; const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); notifyAnilistTokenStoreWarning = (message: string) => { logger.warn(`[AniList] ${message}`); @@ -2181,6 +2182,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), + logWarn: (message) => logger.warn(message), sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), })(), @@ -4190,6 +4194,14 @@ const immersionTrackerStartupMainDeps: Parameters< const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), ); +const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => { + pendingSubtitleMiningContext = context; +}; +const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => { + const context = pendingSubtitleMiningContext; + pendingSubtitleMiningContext = null; + return context; +}; const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordCardsMined(count, noteIds); @@ -5153,6 +5165,7 @@ function initializeOverlayRuntime(): void { appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( refreshCurrentSubtitleAfterKnownWordUpdate, ); + appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); syncOverlayMpvSubtitleSuppression(); } @@ -5948,6 +5961,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), + recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: async () => { @@ -6198,6 +6212,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( refreshCurrentSubtitleAfterKnownWordUpdate, ); + appState.ankiIntegration?.setSubtitleMiningContextConsumer( + consumePendingSubtitleMiningContext, + ); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index d1959a62..a1a36866 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -96,6 +96,7 @@ export interface MainIpcRuntimeServiceDepsParams { getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark']; + recordSubtitleMiningContext?: IpcDepsRuntimeOptions['recordSubtitleMiningContext']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot']; @@ -273,6 +274,7 @@ export function createMainIpcRuntimeServiceDeps( getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark, + recordSubtitleMiningContext: params.recordSubtitleMiningContext, getCharacterDictionarySelection: params.getCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection, getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 5d2f56a0..ad7a52f9 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -804,6 +804,28 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => { assert.equal(await pending, true); }); +test('waitForModalOpen resolves true when modal acknowledgement arrives before waiter registration', async () => { + const modalWindow = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-request', + {}, + { + restoreOnModalClose: 'kiku', + }, + ); + runtime.notifyOverlayModalOpened('kiku'); + + assert.equal(await runtime.waitForModalOpen('kiku', 5), true); +}); + test('waitForModalOpen resolves false on timeout', async () => { const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 47f59912..b9b2401d 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService( ): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); const modalOpenWaiters = new Map void>>(); + const openedModals = new Set(); let modalActive = false; let mainWindowMousePassthroughForcedByModal = false; let mainWindowHiddenByModal = false; @@ -375,6 +376,7 @@ export function createOverlayModalRuntimeService( }; const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { + openedModals.delete(modal); if (!restoreVisibleOverlayOnModalClose.has(modal)) return; restoreVisibleOverlayOnModalClose.delete(modal); const modalWindow = deps.getModalWindow(); @@ -392,6 +394,7 @@ export function createOverlayModalRuntimeService( const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + openedModals.add(modal); const waiters = modalOpenWaiters.get(modal) ?? []; modalOpenWaiters.delete(modal); for (const resolve of waiters) { @@ -420,6 +423,10 @@ export function createOverlayModalRuntimeService( const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise => await new Promise((resolve) => { + if (openedModals.has(modal)) { + resolve(true); + return; + } const waiters = modalOpenWaiters.get(modal) ?? []; const finish = (opened: boolean): void => { clearTimeout(timeout); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.ts b/src/main/runtime/field-grouping-overlay-main-deps.ts index 1dbf8a3c..57a97436 100644 --- a/src/main/runtime/field-grouping-overlay-main-deps.ts +++ b/src/main/runtime/field-grouping-overlay-main-deps.ts @@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps = Omit< sendToActiveOverlayWindow: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: TModal }, + runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean }, ) => boolean; }; @@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions), }); } diff --git a/src/main/runtime/log-export.ts b/src/main/runtime/log-export.ts index b1c06e14..302857d7 100644 --- a/src/main/runtime/log-export.ts +++ b/src/main/runtime/log-export.ts @@ -11,6 +11,7 @@ type LogCandidate = { mtimeMs: number; mtimeDateKey: string; fileDateKey: string | null; + fileWeekKey: string | null; }; export type ExportLogsResult = { @@ -38,10 +39,21 @@ function localDateKey(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } +function localWeekKey(date: Date): string { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const dayOfYear = + Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)) + 1; + return `${date.getFullYear()}-W${pad(Math.max(1, Math.ceil(dayOfYear / 7)))}`; +} + function filenameDateKey(fileName: string): string | null { return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null; } +function filenameWeekKey(fileName: string): string | null { + return fileName.match(/\d{4}-W\d{2}/)?.[0] ?? null; +} + function fileKind(fileName: string): string { const match = fileName.match(/^([A-Za-z0-9_-]+)-/); return match?.[1] ?? fileName; @@ -84,6 +96,7 @@ function buildCandidate(logsDir: string, entry: string): LogCandidate | null { mtimeMs: stats.mtimeMs, mtimeDateKey: localDateKey(stats.mtime), fileDateKey: filenameDateKey(entry), + fileWeekKey: filenameWeekKey(entry), }; } @@ -117,6 +130,14 @@ function candidateFreshnessMs(candidate: LogCandidate): number { if (candidate.fileDateKey) { return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`); } + if (candidate.fileWeekKey) { + const match = candidate.fileWeekKey.match(/^(\d{4})-W(\d{2})$/); + if (match) { + const year = Number(match[1]); + const week = Number(match[2]); + return Date.UTC(year, 0, week * 7, 23, 59, 59, 999); + } + } return candidate.mtimeMs; } @@ -130,6 +151,12 @@ function selectLogCandidates( return { mode: 'current-day', selected: currentDated }; } + const currentWeek = localWeekKey(now); + const currentWeekly = candidates.filter((candidate) => candidate.fileWeekKey === currentWeek); + if (currentWeekly.length > 0) { + return { mode: 'current-day', selected: currentWeekly }; + } + const currentUndated = candidates.filter( (candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today, ); diff --git a/src/preload.ts b/src/preload.ts index e400a114..aa081f87 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -55,6 +55,7 @@ import type { ControllerPreferenceUpdate, ResolvedControllerConfig, SessionNumericSelectionStartPayload, + SubtitleMiningContext, YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, @@ -262,8 +263,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); }, - recordYomitanLookup: () => { - ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup); + recordYomitanLookup: (context?: SubtitleMiningContext | null) => { + ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null); }, getSubtitlePosition: (): Promise => diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index f6e64ff3..b8369225 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o } }); +test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => { + const ctx = createMouseTestContext(); + (ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + const bodyClassList = createClassList(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + body: { classList: bodyClassList }, + querySelectorAll: () => [], + querySelector: () => null, + visibilityState: 'visible', + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupYomitanObserver(); + + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true); + + for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index a7063388..e15b4fa1 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -5,6 +5,7 @@ import { YOMITAN_POPUP_MOUSE_ENTER_EVENT, YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_SHOWN_EVENT, + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; @@ -44,10 +45,21 @@ export function createMouseHandlers( return typeof document !== 'undefined' && isYomitanPopupVisible(document); } + function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void { + if (typeof document === 'undefined') { + return; + } + document.body?.classList?.toggle( + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, + popupVisible && ctx.state.primaryVisibleOnYomitanPopup, + ); + } + function syncPopupVisibilityState(assumeVisible = false): boolean { const popupVisible = assumeVisible || getPopupVisibilityFromDom(); yomitanPopupVisible = popupVisible; ctx.state.yomitanPopupVisible = popupVisible; + syncPrimaryVisibleOnYomitanPopupClass(popupVisible); return popupVisible; } @@ -293,6 +305,7 @@ export function createMouseHandlers( yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false; + syncPrimaryVisibleOnYomitanPopupClass(false); popupPauseRequestId += 1; maybeResumeYomitanPopupPause(); maybeResumeHoverPause(); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 721c3463..51ded15a 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -113,6 +113,88 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0); }); +test('subtitle sidebar mining context resolves selected row cue timing', () => { + const globals = globalThis as typeof globalThis & { + Element?: unknown; + Node?: unknown; + window?: unknown; + }; + const previousElement = globals.Element; + const previousNode = globals.Node; + const previousWindow = globals.window; + + class FakeNode { + parentElement: FakeElement | null = null; + } + class FakeElement extends FakeNode { + dataset: Record = {}; + + closest(selector: string) { + return selector === '.subtitle-sidebar-item' ? this : null; + } + } + + const row = new FakeElement(); + row.dataset.index = '1'; + const textNode = new FakeNode(); + textNode.parentElement = row; + + Object.defineProperty(globalThis, 'Node', { configurable: true, value: FakeNode }); + Object.defineProperty(globalThis, 'Element', { configurable: true, value: FakeElement }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + getSelection: () => ({ anchorNode: textNode, focusNode: null }), + }, + }); + + try { + const state = createRendererState(); + state.subtitleSidebarModalOpen = true; + state.subtitleSidebarCues = [ + { startTime: 1, endTime: 2, text: 'current line' }, + { startTime: 3, endTime: 5, text: 'sidebar previous line' }, + ]; + const modal = createSubtitleSidebarModal( + { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + style: { setProperty: () => {} }, + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + }, + ); + + const context = modal.getSubtitleSidebarMiningContext(); + + assert.equal(context?.source, 'subtitle-sidebar'); + assert.equal(context?.text, 'sidebar previous line'); + assert.equal(context?.startTime, 3); + assert.equal(context?.endTime, 5); + assert.equal(typeof context?.capturedAtMs, 'number'); + } finally { + Object.defineProperty(globalThis, 'Element', { configurable: true, value: previousElement }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + } +}); + test('applySidebarCssDeclarations clears declarations removed by config reload', () => { const removed: string[] = []; const style = { diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 57e4d6b1..4a9d6065 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -1,4 +1,9 @@ -import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types'; +import type { + SubtitleCue, + SubtitleData, + SubtitleMiningContext, + SubtitleSidebarSnapshot, +} from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { @@ -201,6 +206,7 @@ export function createSubtitleSidebarModal( let subtitleSidebarFocusedWithin = false; let subtitleSidebarYomitanPopupVisible = false; let subtitleSidebarPauseHeldByYomitanPopup = false; + let lastSubtitleSidebarLookupCueIndex = -1; function restoreEmbeddedSidebarPassthrough(): void { syncOverlayMouseIgnoreState(ctx); @@ -213,9 +219,75 @@ export function createSubtitleSidebarModal( function clearSidebarInteractionState(): void { subtitleSidebarHovered = false; subtitleSidebarFocusedWithin = false; + lastSubtitleSidebarLookupCueIndex = -1; syncSidebarInteractionState(); } + function findCueIndexFromNode(node: Node | null): number | null { + if (!node || typeof Element === 'undefined') { + return null; + } + const element = node instanceof Element ? node : node.parentElement; + const row = element?.closest('.subtitle-sidebar-item') ?? null; + if (!row) { + return null; + } + const index = Number.parseInt(row.dataset.index ?? '', 10); + if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) { + return null; + } + return index; + } + + function rememberLookupCueFromTarget(target: EventTarget | null): void { + if (typeof Node === 'undefined') { + return; + } + if (!(target instanceof Node)) { + return; + } + const index = findCueIndexFromNode(target); + if (index === null) { + return; + } + lastSubtitleSidebarLookupCueIndex = index; + } + + function getSubtitleSidebarMiningContext(): SubtitleMiningContext | null { + if (!ctx.state.subtitleSidebarModalOpen) { + return null; + } + + const selection = window.getSelection?.() ?? null; + const selectionIndex = + findCueIndexFromNode(selection?.anchorNode ?? null) ?? + findCueIndexFromNode(selection?.focusNode ?? null); + const index = + selectionIndex ?? + (lastSubtitleSidebarLookupCueIndex >= 0 ? lastSubtitleSidebarLookupCueIndex : null); + if (index === null) { + return null; + } + + const cue = ctx.state.subtitleSidebarCues[index]; + if ( + !cue || + !Number.isFinite(cue.startTime) || + !Number.isFinite(cue.endTime) || + cue.endTime <= cue.startTime + ) { + return null; + } + + return { + source: 'subtitle-sidebar', + text: cue.text, + startTime: cue.startTime, + endTime: cue.endTime, + capturedAtMs: Date.now(), + }; + } + function setStatus(message: string): void { ctx.dom.subtitleSidebarStatus.textContent = message; } @@ -653,6 +725,12 @@ export function createSubtitleSidebarModal( ctx.dom.subtitleSidebarList.addEventListener('wheel', () => { ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS; }); + ctx.dom.subtitleSidebarList.addEventListener('pointerover', (event) => { + rememberLookupCueFromTarget(event.target); + }); + ctx.dom.subtitleSidebarList.addEventListener('focusin', (event) => { + rememberLookupCueFromTarget(event.target); + }); ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => { subtitleSidebarHovered = true; syncSidebarInteractionState(); @@ -677,6 +755,9 @@ export function createSubtitleSidebarModal( }); ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => { subtitleSidebarHovered = false; + if (!subtitleSidebarFocusedWithin) { + lastSubtitleSidebarLookupCueIndex = -1; + } syncSidebarInteractionState(); if (ctx.state.isOverSubtitleSidebar) { restoreEmbeddedSidebarPassthrough(); @@ -700,6 +781,7 @@ export function createSubtitleSidebarModal( } subtitleSidebarFocusedWithin = false; + lastSubtitleSidebarLookupCueIndex = -1; syncSidebarInteractionState(); if (ctx.state.isOverSubtitleSidebar) { restoreEmbeddedSidebarPassthrough(); @@ -736,5 +818,6 @@ export function createSubtitleSidebarModal( }, handleSubtitleUpdated, seekToCue, + getSubtitleSidebarMiningContext, }; } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index a4571839..6ef7e4da 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -580,7 +580,7 @@ registerModalOpenHandlers(); registerKeyboardCommandHandlers(); registerYomitanLookupListener(window, () => { runGuarded('yomitan:lookup', () => { - window.electronAPI.recordYomitanLookup(); + window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext()); }); }); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index b3c17b76..dc639b55 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -114,6 +114,7 @@ export type RendererState = { preserveSubtitleLineBreaks: boolean; autoPauseVideoOnSubtitleHover: boolean; autoPauseVideoOnYomitanPopup: boolean; + primaryVisibleOnYomitanPopup: boolean; frequencyDictionaryEnabled: boolean; frequencyDictionaryTopX: number; frequencyDictionaryMode: 'single' | 'banded'; @@ -225,6 +226,7 @@ export function createRendererState(): RendererState { preserveSubtitleLineBreaks: false, autoPauseVideoOnSubtitleHover: false, autoPauseVideoOnYomitanPopup: false, + primaryVisibleOnYomitanPopup: true, frequencyDictionaryEnabled: false, frequencyDictionaryTopX: 1000, frequencyDictionaryMode: 'single', diff --git a/src/renderer/style.css b/src/renderer/style.css index e4b907b2..cdfcd3e0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer { opacity: 1; } +body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover { + opacity: 1; +} + #subtitleContainer.primary-sub-hidden { display: none; pointer-events: none; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index facbf6fe..fa5cce98 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo ); assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/); + const primaryHoverYomitanPopupVisibleBlock = extractClassBlock( + cssText, + 'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover', + ); + assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/); + const secondaryEmbeddedHoverBlock = extractClassBlock( cssText, 'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover', diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 417aa33c..81b50a3d 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -6,6 +6,7 @@ import type { SubtitleRendererStyleConfig, } from '../types'; import type { RendererContext } from './context'; +import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; type FrequencyRenderSettings = { enabled: boolean; @@ -259,6 +260,13 @@ function applySubtitleCssDeclarations( ); } +function syncPrimaryVisibleOnYomitanPopupClass(ctx: RendererContext): void { + document.body?.classList?.toggle( + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, + ctx.state.yomitanPopupVisible && ctx.state.primaryVisibleOnYomitanPopup, + ); +} + function pickInlineStyleDeclarations( declarations: Record, includedKeys: ReadonlySet, @@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false; ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false; + ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true; + syncPrimaryVisibleOnYomitanPopupClass(ctx); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 2d8006b6..e3301e5e 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter'; export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave'; export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; +export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup'; export function registerYomitanLookupListener( target: EventTarget = window, diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 0abad13c..b81c0706 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -29,6 +29,7 @@ import type { ResolvedSubtitleSidebarConfig, SecondarySubMode, SubtitleData, + SubtitleMiningContext, SubtitlePosition, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, @@ -413,7 +414,7 @@ export interface ElectronAPI { onSubtitleAss: (callback: (assText: string) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; openYomitanSettings: () => void; - recordYomitanLookup: () => void; + recordYomitanLookup: (context?: SubtitleMiningContext | null) => void; getSubtitlePosition: () => Promise; saveSubtitlePosition: (position: SubtitlePosition) => void; getMecabStatus: () => Promise; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 0d388137..2fb6c4c9 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -81,6 +81,7 @@ export interface SubtitleStyleConfig { preserveLineBreaks?: boolean; autoPauseVideoOnHover?: boolean; autoPauseVideoOnYomitanPopup?: boolean; + primaryVisibleOnYomitanPopup?: boolean; hoverTokenColor?: string; hoverTokenBackgroundColor?: string; nameMatchEnabled?: boolean; @@ -217,6 +218,14 @@ export interface SubtitleSidebarSnapshot { config: SubtitleSidebarSnapshotConfig; } +export interface SubtitleMiningContext { + source: 'subtitle-sidebar'; + text: string; + startTime: number; + endTime: number; + capturedAtMs?: number; +} + export interface SubtitleHoverTokenPayload { tokenIndex: number | null; }