mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
fix: Kiku field grouping, frequency particles, sidebar media, Yomitan po
- 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
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: config
|
||||
|
||||
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
|
||||
@@ -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.
|
||||
+83
-148
@@ -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.
|
||||
}
|
||||
|
||||
+157
-156
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
fieldGroupingService: {
|
||||
triggerFieldGroupingForLastAddedCard: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
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,
|
||||
'<span data-group-id="101">[sound:keep.mp3]</span><span data-group-id="202">[sound:new.mp3]</span>',
|
||||
'<span data-group-id="202">[sound:new.mp3]</span><span data-group-id="101">[sound:keep.mp3]</span>',
|
||||
);
|
||||
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, '<span data-group-id="22">[sound:generated.mp3]</span>');
|
||||
});
|
||||
|
||||
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, '<span data-group-id="202">same sentence</span>');
|
||||
assert.equal(merged.SentenceAudio, '<span data-group-id="202">[sound:same.mp3]</span>');
|
||||
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
|
||||
assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
|
||||
);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
|
||||
test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
|
||||
|
||||
+102
-27
@@ -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<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
|
||||
@@ -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}<b>${highlightedText}</b>${suffix}`;
|
||||
}
|
||||
|
||||
private async generateAudio(): Promise<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
private async generateImage(
|
||||
animatedLeadInSeconds = 0,
|
||||
context?: SubtitleMiningContext,
|
||||
): Promise<Buffer | null> {
|
||||
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<boolean> {
|
||||
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<number>,
|
||||
): 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<number>();
|
||||
|
||||
@@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record<string, string>): 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: '<img src="original.png">',
|
||||
MiscInfo: 'original misc',
|
||||
ExpressionAudio: '[sound:word.mp3]',
|
||||
}),
|
||||
makeNote(2, {
|
||||
makeNote(200, {
|
||||
Sentence: 'new sentence',
|
||||
SentenceAudio: '[sound:new.mp3]',
|
||||
Picture: '<img src="new.png">',
|
||||
MiscInfo: 'new misc',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
|
||||
merged.Sentence,
|
||||
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="300">[sound:original-a.mp3] [sound:original-b.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
||||
);
|
||||
assert.equal(
|
||||
merged.MiscInfo,
|
||||
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
||||
);
|
||||
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: '<img src="new.png">',
|
||||
MiscInfo: 'new misc',
|
||||
}),
|
||||
makeNote(300, {
|
||||
Sentence: 'original sentence',
|
||||
SentenceAudio: '[sound:original.mp3]',
|
||||
Picture: '<img src="original.png">',
|
||||
MiscInfo: 'original misc',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="300">[sound:original.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
||||
);
|
||||
assert.equal(
|
||||
merged.MiscInfo,
|
||||
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
||||
);
|
||||
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,
|
||||
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>',
|
||||
'<span data-group-id="4">source sentence</span><span data-group-id="3"><span data-group-id="abc">keep sentence</span></span>',
|
||||
);
|
||||
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
|
||||
assert.equal(warnings.length, 4);
|
||||
@@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
|
||||
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
|
||||
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
|
||||
});
|
||||
|
||||
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, '');
|
||||
});
|
||||
|
||||
@@ -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 = /<span\s+data-group-id="[^"]*">[\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(/<img\b[^>]*>/gi);
|
||||
if (!matches || matches.length === 0) return '';
|
||||
return matches[matches.length - 1]!;
|
||||
private extractUngroupedRemainder(value: string): string {
|
||||
const groupedSpanRegex = /<span\b[^>]*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 = /<span\s+data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
const spanRegex = /<span\b[^>]*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<string>();
|
||||
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<string> {
|
||||
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<T extends { groupId: number }>(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) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
.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) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an
|
||||
type NoteInfo = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
type ManualChoice = {
|
||||
@@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: {
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
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<string, string>) => {
|
||||
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();
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
export interface FieldGroupingWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface FieldGroupingWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
addTags(noteIds: number[], tags: string[]): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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<string, string> }> = [];
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<number>;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
generateAudioFilename: () => string;
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
generateAudio: (context?: SubtitleMiningContext) => Promise<Buffer | null>;
|
||||
generateImageFilename: () => string;
|
||||
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
|
||||
generateImage: (
|
||||
animatedLeadInSeconds?: number,
|
||||
context?: SubtitleMiningContext,
|
||||
) => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
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<string, string>,
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: false,
|
||||
|
||||
@@ -57,6 +57,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
description:
|
||||
'Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.hoverTokenColor',
|
||||
kind: 'string',
|
||||
|
||||
@@ -186,6 +186,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
@@ -333,6 +335,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const primaryVisibleOnYomitanPopup = asBoolean(
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup,
|
||||
);
|
||||
if (primaryVisibleOnYomitanPopup !== undefined) {
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup = primaryVisibleOnYomitanPopup;
|
||||
} else if (
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup !== undefined
|
||||
) {
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup =
|
||||
fallbackSubtitleStylePrimaryVisibleOnYomitanPopup;
|
||||
warn(
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup,
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenColor = asColor(
|
||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
||||
);
|
||||
|
||||
@@ -128,6 +128,33 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle primaryVisibleOnYomitanPopup falls back on invalid value', () => {
|
||||
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: {
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ const PATH_ORDER = new Map<string, number>(
|
||||
'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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
|
||||
assert.equal(visible, false);
|
||||
assert.deepEqual(visibilityTransitions, [true, false]);
|
||||
});
|
||||
|
||||
async function settleWithinMicrotasks<T>(
|
||||
promise: Promise<T>,
|
||||
attempts = 10,
|
||||
): Promise<T | 'timeout'> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<T extends string> {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
|
||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||
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<T extends string>(
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
@@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
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<T extends string>(
|
||||
});
|
||||
};
|
||||
|
||||
const sendKikuFieldGroupingRequest = async (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
): Promise<boolean> => {
|
||||
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<KikuFieldGroupingChoice>) => {
|
||||
@@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
sendKikuFieldGroupingRequest,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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<boolean>;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: {
|
||||
|
||||
const previousVisibleOverlay = options.getVisibleOverlayVisible();
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
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<string, unknown>;
|
||||
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<void>;
|
||||
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
@@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime<T extends string>(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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
'第二走者',
|
||||
|
||||
@@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
||||
if (parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Frequency highlighting should be conservative: if any merged component is excluded,
|
||||
// skip highlighting the whole token to avoid noisy merged fragments.
|
||||
return parts.some((part) => exclusions.has(part));
|
||||
|
||||
return parts.every((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||
@@ -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<string>,
|
||||
): 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;
|
||||
|
||||
+19
-2
@@ -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<OverlayHos
|
||||
setResolver: (resolver) => 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService(
|
||||
): OverlayModalRuntime {
|
||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
|
||||
const openedModals = new Set<OverlayHostedModal>();
|
||||
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<boolean> =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
if (openedModals.has(modal)) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
const finish = (opened: boolean): void => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps<TModal extends string> = Omit<
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
|
||||
) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
+3
-2
@@ -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<SubtitlePosition | null> =>
|
||||
|
||||
@@ -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<string, Array<() => 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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<HTMLElement>('.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ registerModalOpenHandlers();
|
||||
registerKeyboardCommandHandlers();
|
||||
registerYomitanLookupListener(window, () => {
|
||||
runGuarded('yomitan:lookup', () => {
|
||||
window.electronAPI.recordYomitanLookup();
|
||||
window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
includedKeys: ReadonlySet<string>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SubtitlePosition | null>;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabStatus: () => Promise<MecabStatus>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user