diff --git a/changes/fix-kiku-duplicate-modal-open.md b/changes/fix-kiku-duplicate-modal-open.md new file mode 100644 index 00000000..2ab71fd7 --- /dev/null +++ b/changes/fix-kiku-duplicate-modal-open.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics. diff --git a/changes/frequency-compound-particles.md b/changes/frequency-compound-particles.md new file mode 100644 index 00000000..dda8022b --- /dev/null +++ b/changes/frequency-compound-particles.md @@ -0,0 +1,4 @@ +type: fixed +area: subtitles + +- Fixed frequency annotations for Yomitan single-token compounds with internal particles, such as `目の前`, while keeping pure grammar/kana helper spans unannotated. diff --git a/changes/primary-visible-yomitan-popup.md b/changes/primary-visible-yomitan-popup.md new file mode 100644 index 00000000..3bd42332 --- /dev/null +++ b/changes/primary-visible-yomitan-popup.md @@ -0,0 +1,4 @@ +type: added +area: config + +- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open. diff --git a/changes/sentence-card-secondary-subtitle.md b/changes/sentence-card-secondary-subtitle.md new file mode 100644 index 00000000..c37592a4 --- /dev/null +++ b/changes/sentence-card-secondary-subtitle.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Sentence cards now refresh the current secondary subtitle before saving, so SelectionText uses the loaded translation instead of repeating the primary subtitle. diff --git a/changes/subtitle-sidebar-mining-media.md b/changes/subtitle-sidebar-mining-media.md new file mode 100644 index 00000000..40a26f8d --- /dev/null +++ b/changes/subtitle-sidebar-mining-media.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed subtitle sidebar mining so Yomitan-enriched cards use audio and images from the clicked sidebar line instead of the current primary subtitle line. diff --git a/config.example.jsonc b/config.example.jsonc index 7e0078e2..043d5552 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -389,6 +389,7 @@ "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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index f88d091f..3074a65c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -272,8 +272,8 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| --------- | ------------------------- | --------------------------------------------------- | +| Option | Values | Description | +| ------------------- | ------------------------- | --------------------------------------------------- | | `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) | | `websocket.port` | number | WebSocket server port (default: 6677) | @@ -292,8 +292,8 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen } ``` -| Option | Values | Description | -| --------- | --------------- | -------------------------------------------------------------- | +| Option | Values | Description | +| ----------------------------- | --------------- | -------------------------------------------------------------- | | `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) | @@ -358,35 +358,36 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) | -| `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 | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) | +| `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 @@ -439,25 +440,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. @@ -622,26 +623,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. @@ -769,19 +770,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:** @@ -856,7 +857,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`) | @@ -940,57 +941,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. @@ -1145,15 +1146,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. @@ -1244,21 +1245,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. @@ -1296,12 +1297,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ } ``` -| Option | Values | Description | -| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- | +| Option | Values | Description | +| ------------------------- | ------------------------------------------------ | ---------------------------------------------------------- | | `discordPresence.enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | -| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | -| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | -| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | +| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | +| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | +| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | Setup steps: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 7e0078e2..043d5552 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -389,6 +389,7 @@ "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. diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index a6531348..04ffe9d9 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -288,6 +288,48 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis assert.equal(privateState.runtime.proxyServer, null); }); +test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => { + const integration = new AnkiIntegration( + { + isKiku: { + enabled: true, + fieldGrouping: 'manual', + }, + } as never, + {} as never, + {} as never, + ); + + let groupingTriggered = 0; + const internals = integration as unknown as { + cardCreationService: { + createSentenceCard: ( + sentence: string, + startTime: number, + endTime: number, + secondarySubText?: string, + ) => Promise; + }; + fieldGroupingService: { + triggerFieldGroupingForLastAddedCard: () => Promise; + }; + }; + internals.cardCreationService = { + createSentenceCard: async () => { + integration.trackDuplicateNoteIdsForNote(42, [7]); + return true; + }, + }; + internals.fieldGroupingService = { + triggerFieldGroupingForLastAddedCard: async () => { + groupingTriggered += 1; + }, + }; + + assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true); + assert.equal(groupingTriggered, 1); +}); + test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => { const osdMessages: string[] = []; const integration = new AnkiIntegration( @@ -316,7 +358,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']); }); -test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { +test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( @@ -340,9 +382,9 @@ test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged Se assert.equal( merged.SentenceAudio, - '[sound:keep.mp3][sound:new.mp3]', + '[sound:new.mp3][sound:keep.mp3]', ); - assert.equal(merged.ExpressionAudio, merged.SentenceAudio); + assert.equal('ExpressionAudio' in merged, false); }); test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => { @@ -374,7 +416,7 @@ test('FieldGroupingMergeCollaborator uses generated media fallback when source l assert.equal(merged.SentenceAudio, '[sound:generated.mp3]'); }); -test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and image values when merging into a new duplicate card', async () => { +test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => { const collaborator = createFieldGroupingMergeCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( @@ -400,10 +442,19 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and false, ); - assert.equal(merged.Sentence, 'same sentence'); - assert.equal(merged.SentenceAudio, '[sound:same.mp3]'); - assert.equal(merged.Picture, ''); - assert.equal(merged.ExpressionAudio, merged.SentenceAudio); + assert.equal( + merged.Sentence, + 'same sentencesame sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:same.mp3][sound:same.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal('ExpressionAudio' in merged, false); }); test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { diff --git a/src/anki-integration.ts b/src/anki-integration.ts index f477788b..ae6fc039 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -29,7 +29,7 @@ import { } from './types/anki'; import { AiConfig } from './types/integrations'; import { MpvClient } from './types/runtime'; -import { NPlusOneMatchMode } from './types/subtitle'; +import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle'; import { DEFAULT_ANKI_CONNECT_CONFIG } from './config'; import { getConfiguredWordFieldCandidates, @@ -149,6 +149,7 @@ export class AnkiIntegration { private aiConfig: AiConfig; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null; + private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private noteIdRedirects = new Map(); private trackedDuplicateNoteIds = new Map(); @@ -453,11 +454,13 @@ export class AnkiIntegration { mergeFieldValue: (existing, newValue, overwrite) => this.mergeFieldValue(existing, newValue, overwrite), generateAudioFilename: () => this.generateAudioFilename(), - generateAudio: () => this.generateAudio(), + generateAudio: (context) => this.generateAudio(context), generateImageFilename: () => this.generateImageFilename(), - generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds), + generateImage: (animatedLeadInSeconds, context) => + this.generateImage(animatedLeadInSeconds, context), formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), + consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), showNotification: (noteId, label) => this.showNotification(noteId, label), showOsdNotification: (message) => this.showOsdNotification(message), @@ -474,6 +477,7 @@ export class AnkiIntegration { client: { notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown, updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields), + addTags: (noteIds, tags) => this.client.addTags(noteIds, tags), deleteNotes: (noteIds) => this.client.deleteNotes(noteIds), }, getConfig: () => this.config, @@ -673,7 +677,55 @@ export class AnkiIntegration { return `${prefix}${highlightedText}${suffix}`; } - private async generateAudio(): Promise { + private consumeSubtitleMiningContext(): SubtitleMiningContext | null { + if (!this.consumeSubtitleMiningContextCallback) { + return null; + } + + try { + return this.consumeSubtitleMiningContextCallback(); + } catch (error) { + log.warn('Subtitle mining context callback failed:', (error as Error).message); + return null; + } + } + + private getSubtitleMediaRange(context?: SubtitleMiningContext): { + startTime: number; + endTime: number; + } { + if ( + context && + Number.isFinite(context.startTime) && + Number.isFinite(context.endTime) && + context.endTime > context.startTime + ) { + return { + startTime: context.startTime, + endTime: context.endTime, + }; + } + + if ( + Number.isFinite(this.mpvClient.currentSubStart) && + Number.isFinite(this.mpvClient.currentSubEnd) && + this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart + ) { + return { + startTime: this.mpvClient.currentSubStart, + endTime: this.mpvClient.currentSubEnd, + }; + } + + const currentTime = this.mpvClient.currentTimePos || 0; + const fallback = this.getFallbackDurationSeconds() / 2; + return { + startTime: currentTime - fallback, + endTime: currentTime + fallback, + }; + } + + private async generateAudio(context?: SubtitleMiningContext): Promise { const mpvClient = this.mpvClient; if (!mpvClient || !mpvClient.currentVideoPath) { return null; @@ -683,15 +735,7 @@ export class AnkiIntegration { if (!videoPath) { return null; } - let startTime = mpvClient.currentSubStart; - let endTime = mpvClient.currentSubEnd; - - if (startTime === undefined || endTime === undefined) { - const currentTime = mpvClient.currentTimePos || 0; - const fallback = this.getFallbackDurationSeconds() / 2; - startTime = currentTime - fallback; - endTime = currentTime + fallback; - } + const { startTime, endTime } = this.getSubtitleMediaRange(context); return this.mediaGenerator.generateAudio( videoPath, @@ -702,7 +746,10 @@ export class AnkiIntegration { ); } - private async generateImage(animatedLeadInSeconds = 0): Promise { + private async generateImage( + animatedLeadInSeconds = 0, + context?: SubtitleMiningContext, + ): Promise { if (!this.mpvClient || !this.mpvClient.currentVideoPath) { return null; } @@ -711,22 +758,16 @@ export class AnkiIntegration { if (!videoPath) { return null; } - const timestamp = this.mpvClient.currentTimePos || 0; + const mediaRange = this.getSubtitleMediaRange(context); + const timestamp = context + ? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2 + : this.mpvClient.currentTimePos || 0; if (this.config.media?.imageType === 'avif') { - let startTime = this.mpvClient.currentSubStart; - let endTime = this.mpvClient.currentSubEnd; - - if (startTime === undefined || endTime === undefined) { - const fallback = this.getFallbackDurationSeconds() / 2; - startTime = timestamp - fallback; - endTime = timestamp + fallback; - } - return this.mediaGenerator.generateAnimatedImage( videoPath, - startTime, - endTime, + mediaRange.startTime, + mediaRange.endTime, this.config.media?.audioPadding, { fps: this.config.media?.animatedFps, @@ -1064,18 +1105,48 @@ export class AnkiIntegration { endTime: number, secondarySubText?: string, ): Promise { - return this.cardCreationService.createSentenceCard( + const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys()); + const created = await this.cardCreationService.createSentenceCard( sentence, startTime, endTime, secondarySubText, ); + if ( + created && + this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( + trackedDuplicateNoteIdsBeforeCreate, + ) + ) { + try { + await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard(); + } catch (error) { + log.warn('Failed to trigger field grouping after sentence card creation:', error); + } + } + return created; } trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void { this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]); } + private shouldTriggerFieldGroupingAfterLocalSentenceCardCreate( + trackedDuplicateNoteIdsBeforeCreate: Set, + ): boolean { + const sentenceCardConfig = this.getEffectiveSentenceCardConfig(); + if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') { + return false; + } + + for (const noteId of this.trackedDuplicateNoteIds.keys()) { + if (!trackedDuplicateNoteIdsBeforeCreate.has(noteId)) { + return true; + } + } + return false; + } + private async findDuplicateNote( expression: string, excludeNoteId: number, @@ -1287,6 +1358,10 @@ export class AnkiIntegration { this.knownWordCacheUpdatedCallback = callback; } + setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void { + this.consumeSubtitleMiningContextCallback = callback; + } + resolveCurrentNoteId(noteId: number): number { let resolved = noteId; const seen = new Set(); diff --git a/src/anki-integration/field-grouping-merge.test.ts b/src/anki-integration/field-grouping-merge.test.ts index 18deec37..1a1494e3 100644 --- a/src/anki-integration/field-grouping-merge.test.ts +++ b/src/anki-integration/field-grouping-merge.test.ts @@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record): FieldGrouping }; } -test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => { +test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => { const { collaborator } = createCollaborator({ config: { fields: { image: 'Illustration', sentence: 'SentenceText', - audio: 'ExpressionAudio', + audio: 'CustomWordAudio', miscInfo: 'ExtraInfo', }, }, @@ -97,33 +97,84 @@ test('getGroupableFieldNames includes configured fields without duplicating Expr ]); }); -test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => { - const { collaborator } = createCollaborator({ - config: { - fields: { - audio: 'CustomAudio', - }, - }, - }); +test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => { + const { collaborator } = createCollaborator(); const merged = await collaborator.computeFieldGroupingMergedFields( - 1, - 2, - makeNote(1, { - SentenceAudio: '[sound:keep.mp3]', - CustomAudio: '[sound:stale.mp3]', + 300, + 200, + makeNote(300, { + Sentence: 'original sentence', + SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]', + Picture: '', + MiscInfo: 'original misc', + ExpressionAudio: '[sound:word.mp3]', }), - makeNote(2, { + makeNote(200, { + Sentence: 'new sentence', SentenceAudio: '[sound:new.mp3]', + Picture: '', + MiscInfo: 'new misc', }), false, ); assert.equal( - merged.SentenceAudio, - '[sound:keep.mp3][sound:new.mp3]', + merged.Sentence, + 'original sentencenew sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:original-a.mp3] [sound:original-b.mp3][sound:new.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal( + merged.MiscInfo, + 'original miscnew misc', + ); + assert.equal('ExpressionAudio' in merged, false); +}); + +test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => { + const { collaborator } = createCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 200, + 300, + makeNote(200, { + Sentence: 'new sentence', + SentenceAudio: '[sound:new.mp3]', + Picture: '', + MiscInfo: 'new misc', + }), + makeNote(300, { + Sentence: 'original sentence', + SentenceAudio: '[sound:original.mp3]', + Picture: '', + MiscInfo: 'original misc', + }), + false, + ); + + assert.equal( + merged.Sentence, + 'original sentencenew sentence', + ); + assert.equal( + merged.SentenceAudio, + '[sound:original.mp3][sound:new.mp3]', + ); + assert.equal( + merged.Picture, + '', + ); + assert.equal( + merged.MiscInfo, + 'original miscnew misc', ); - assert.equal(merged.CustomAudio, merged.SentenceAudio); }); test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => { @@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty assert.equal( merged.Sentence, - 'keep sentencesource sentence', + 'source sentencekeep sentence', ); assert.equal(merged.SentenceAudio, '[sound:source.mp3]'); assert.equal(warnings.length, 4); @@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen assert.equal(withMedia.Picture, ''); assert.equal(withMedia.MiscInfo, 'generated misc'); }); + +test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => { + const { collaborator } = createCollaborator(); + + const merged = await collaborator.computeFieldGroupingMergedFields( + 300, + 200, + makeNote(300, { + SentenceFurigana: 'original furigana', + }), + makeNote(200, { + SentenceFurigana: '', + }), + false, + ); + + assert.equal(merged.SentenceFurigana, ''); +}); diff --git a/src/anki-integration/field-grouping-merge.ts b/src/anki-integration/field-grouping-merge.ts index 5ddccedb..d7def9ef 100644 --- a/src/anki-integration/field-grouping-merge.ts +++ b/src/anki-integration/field-grouping-merge.ts @@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator { fields.push('Picture'); if (config.fields?.image) fields.push(config.fields?.image); if (config.fields?.sentence) fields.push(config.fields?.sentence); - if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') { - fields.push(config.fields?.audio); - } const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); const sentenceAudioField = sentenceCardConfig.audioField; if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField); @@ -94,12 +91,6 @@ export class FieldGroupingMergeCollaborator { } } - if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) { - sourceFields['SentenceFurigana'] = sourceFields['Sentence']; - } - if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) { - sourceFields['Sentence'] = sourceFields['SentenceFurigana']; - } if (!sourceFields[configuredWordField] && sourceFields['Expression']) { sourceFields[configuredWordField] = sourceFields['Expression']; } @@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator { if (!sourceFields['Word'] && sourceFields[configuredWordField]) { sourceFields['Word'] = sourceFields[configuredWordField]; } - if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) { - sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio']; - } - if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) { - sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio']; - } - if ( config.fields?.sentence && !sourceFields[config.fields?.sentence] && @@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator { const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName); if (!existingValue.trim() && !newValue.trim()) continue; + if (keepFieldNormalized === 'sentencefurigana') { + mergedFields[keepFieldName] = + existingValue.trim() && newValue.trim() + ? this.applyFieldGrouping( + existingValue, + newValue, + keepNoteId, + deleteNoteId, + keepFieldName, + ) + : ''; + continue; + } + if (isStrictField) { mergedFields[keepFieldName] = this.applyFieldGrouping( existingValue, @@ -191,29 +189,6 @@ export class FieldGroupingMergeCollaborator { } } - const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); - const resolvedSentenceAudioField = this.deps.resolveFieldName( - keepFieldNames, - sentenceCardConfig.audioField || 'SentenceAudio', - ); - const resolvedExpressionAudioField = this.deps.resolveFieldName( - keepFieldNames, - config.fields?.audio || 'ExpressionAudio', - ); - if ( - resolvedSentenceAudioField && - resolvedExpressionAudioField && - resolvedExpressionAudioField !== resolvedSentenceAudioField - ) { - const mergedSentenceAudioValue = - mergedFields[resolvedSentenceAudioField] || - keepNoteInfo.fields[resolvedSentenceAudioField]?.value || - ''; - if (mergedSentenceAudioValue.trim()) { - mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue; - } - } - return mergedFields; } @@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator { } private extractUngroupedValue(value: string): string { - const groupedSpanRegex = /[\s\S]*?<\/span>/gi; - const ungrouped = value.replace(groupedSpanRegex, '').trim(); + const ungrouped = this.extractUngroupedRemainder(value); if (ungrouped) return ungrouped; return value.trim(); } - private extractLastSoundTag(value: string): string { - const matches = value.match(/\[sound:[^\]]+\]/g); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; - } - - private extractLastImageTag(value: string): string { - const matches = value.match(/]*>/gi); - if (!matches || matches.length === 0) return ''; - return matches[matches.length - 1]!; + private extractUngroupedRemainder(value: string): string { + const groupedSpanRegex = /]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi; + return value.replace(groupedSpanRegex, '').trim(); } private extractImageTags(value: string): string[] { @@ -274,7 +241,7 @@ export class FieldGroupingMergeCollaborator { } } - const spanRegex = /]*>([\s\S]*?)<\/span>/gi; + const spanRegex = /]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi; let match; while ((match = spanRegex.exec(value)) !== null) { const groupId = Number(match[1]); @@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator { fieldName: string, ): { groupId: number; content: string }[] { const entries = this.extractSpanEntries(value, fieldName); - if (entries.length === 0) { - const ungrouped = this.normalizeStrictGroupedValue( - this.extractUngroupedValue(value), - fieldName, - ); - if (ungrouped) { - entries.push({ groupId: fallbackGroupId, content: ungrouped }); - } + const ungroupedSource = + entries.length > 0 + ? this.extractUngroupedRemainder(value) + : this.extractUngroupedValue(value); + const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName); + if (ungrouped) { + entries.push({ groupId: fallbackGroupId, content: ungrouped }); } - const unique: { groupId: number; content: string }[] = []; - const seen = new Set(); - for (const entry of entries) { - const key = entry.content; - if (seen.has(key)) continue; - seen.add(key); - unique.push(entry); - } - return unique; + return entries; } private parsePictureEntries( @@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator { if (!ungrouped) return ''; const normalizedField = fieldName.toLowerCase(); - if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') { - const lastSoundTag = this.extractLastSoundTag(ungrouped); - if (!lastSoundTag) { - this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); - } - return lastSoundTag || ungrouped; - } - - if (normalizedField === 'picture') { - const lastImageTag = this.extractLastImageTag(ungrouped); - if (!lastImageTag) { - this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag'); - } - return lastImageTag || ungrouped; + if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) { + this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag'); } return ungrouped; } - private getPictureDedupKey(tag: string): string { - return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim(); - } - private getStrictSpanGroupingFields(): Set { const strictFields = new Set(this.strictGroupingFieldDefaults); const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig(); @@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator { return this.getStrictSpanGroupingFields().has(normalized); } + private isPictureField(fieldName: string): boolean { + const normalized = fieldName.toLowerCase(); + const configuredImageField = this.deps.getConfig().fields?.image?.toLowerCase(); + return normalized === 'picture' || normalized === configuredImageField; + } + + private sortEntriesByGroupIdDescending(entries: T[]): T[] { + return [...entries].sort((a, b) => b.groupId - a.groupId); + } + private applyFieldGrouping( existingValue: string, newValue: string, @@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator { fieldName: string, ): string { if (this.shouldUseStrictSpanGrouping(fieldName)) { - if (fieldName.toLowerCase() === 'picture') { + if (this.isPictureField(fieldName)) { const keepEntries = this.parsePictureEntries(existingValue, keepGroupId); const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId); if (keepEntries.length === 0 && sourceEntries.length === 0) { return existingValue || newValue; } - const mergedTags = keepEntries.map((entry) => - this.ensureImageGroupId(entry.tag, entry.groupId), - ); - const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag))); - for (const entry of sourceEntries) { - const normalized = this.ensureImageGroupId(entry.tag, entry.groupId); - const dedupKey = this.getPictureDedupKey(normalized); - if (seen.has(dedupKey)) continue; - seen.add(dedupKey); - mergedTags.push(normalized); - } - return mergedTags.join(''); + return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]) + .map((entry) => entry.tag) + .join(''); } const keepEntries = this.parseStrictEntries(existingValue, keepGroupId, fieldName); @@ -423,19 +366,7 @@ export class FieldGroupingMergeCollaborator { if (keepEntries.length === 0 && sourceEntries.length === 0) { return existingValue || newValue; } - if (sourceEntries.length === 0) { - return keepEntries - .map((entry) => `${entry.content}`) - .join(''); - } - const merged = [...keepEntries]; - const seen = new Set(keepEntries.map((entry) => entry.content)); - for (const entry of sourceEntries) { - const key = entry.content; - if (seen.has(key)) continue; - seen.add(key); - merged.push(entry); - } + const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]); if (merged.length === 0) return existingValue; return merged .map((entry) => `${entry.content}`) diff --git a/src/anki-integration/field-grouping-workflow.test.ts b/src/anki-integration/field-grouping-workflow.test.ts index 361ae041..be21a17a 100644 --- a/src/anki-integration/field-grouping-workflow.test.ts +++ b/src/anki-integration/field-grouping-workflow.test.ts @@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an type NoteInfo = { noteId: number; fields: Record; + tags?: string[]; }; type ManualChoice = { @@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: { function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; const deleted: number[][] = []; + const addedTags: Array<{ noteIds: number[]; tags: string[] }> = []; const statuses: string[] = []; const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = []; const mergeCalls: Array<{ @@ -49,6 +51,9 @@ function createWorkflowHarness() { updateNoteFields: async (noteId: number, fields: Record) => { updates.push({ noteId, fields }); }, + addTags: async (noteIds: number[], tags: string[]) => { + addedTags.push({ noteIds, tags }); + }, deleteNotes: async (noteIds: number[]) => { deleted.push(noteIds); }, @@ -117,6 +122,7 @@ function createWorkflowHarness() { workflow: new FieldGroupingWorkflow(deps), updates, deleted, + addedTags, rememberedMerges, statuses, mergeCalls, @@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b assert.equal(harness.statuses.length, 1); }); +test('FieldGroupingWorkflow merges source tags into target and filters special source tags', async () => { + const harness = createWorkflowHarness(); + harness.deps.client.notesInfo = async (noteIds: number[]) => + noteIds.map((noteId) => ({ + noteId, + fields: { + Expression: { value: `word-${noteId}` }, + Sentence: { value: `line-${noteId}` }, + }, + tags: + noteId === 1 ? ['kinkoi', 'marked'] : ['SubMiner', 'marked', 'leech', 'potential_leech'], + })); + + await harness.workflow.handleAuto(1, 2, { + noteId: 2, + fields: { + Expression: { value: 'word-2' }, + Sentence: { value: 'line-2' }, + }, + tags: ['SubMiner', 'marked', 'leech', 'potential_leech'], + }); + + assert.deepEqual(harness.addedTags, [{ noteIds: [1], tags: ['SubMiner'] }]); +}); + test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => { const harness = createWorkflowHarness(); diff --git a/src/anki-integration/field-grouping-workflow.ts b/src/anki-integration/field-grouping-workflow.ts index 3369bd54..0f42656f 100644 --- a/src/anki-integration/field-grouping-workflow.ts +++ b/src/anki-integration/field-grouping-workflow.ts @@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; export interface FieldGroupingWorkflowNoteInfo { noteId: number; fields: Record; + tags?: string[]; } export interface FieldGroupingWorkflowDeps { client: { notesInfo(noteIds: number[]): Promise; updateNoteFields(noteId: number, fields: Record): Promise; + addTags(noteIds: number[], tags: string[]): Promise; deleteNotes(noteIds: number[]): Promise; }; getConfig: () => { @@ -156,6 +158,11 @@ export class FieldGroupingWorkflow { await this.deps.addConfiguredTagsToNote(keepNoteId); } + const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo); + if (tagsToAdd.length > 0) { + await this.deps.client.addTags([keepNoteId], tagsToAdd); + } + if (deleteDuplicate) { await this.deps.client.deleteNotes([deleteNoteId]); this.deps.removeTrackedNoteId(deleteNoteId); @@ -200,6 +207,24 @@ export class FieldGroupingWorkflow { return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()); } + private getMergeTagsToAdd( + keepNoteInfo: FieldGroupingWorkflowNoteInfo, + deleteNoteInfo: FieldGroupingWorkflowNoteInfo, + ): string[] { + const targetTags = new Set((keepNoteInfo.tags ?? []).map((tag) => tag.trim()).filter(Boolean)); + const unwantedSourceTags = new Set(['leech', 'marked', 'potential_leech']); + const tagsToAdd: string[] = []; + + for (const rawTag of deleteNoteInfo.tags ?? []) { + const tag = rawTag.trim(); + if (!tag || targetTags.has(tag) || unwantedSourceTags.has(tag)) continue; + targetTags.add(tag); + tagsToAdd.push(tag); + } + + return tagsToAdd; + } + private async resolveFieldGroupingCallback(): Promise< | ((data: { original: KikuDuplicateCardInfo; diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index 49e259f0..218236c0 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -5,6 +5,7 @@ import { type NoteUpdateWorkflowDeps, type NoteUpdateWorkflowNoteInfo, } from './note-update-workflow'; +import type { SubtitleMiningContext } from '../types/subtitle'; function createWorkflowHarness() { const updates: Array<{ noteId: number; fields: Record }> = []; @@ -203,3 +204,72 @@ test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word assert.equal(receivedLeadInSeconds, 1.25); }); + +test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => { + const harness = createWorkflowHarness(); + const sidebarContext = { + source: 'subtitle-sidebar' as const, + text: 'sidebar previous line', + startTime: 10, + endTime: 12, + capturedAtMs: 123, + }; + let audioContext: unknown = null; + let imageContext: unknown = null; + let miscInfoStartTime: number | undefined; + + harness.deps.client.notesInfo = async () => + [ + { + noteId: 42, + fields: { + Expression: { value: 'taberu' }, + Sentence: { value: 'sidebar previous line' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + MiscInfo: { value: '' }, + }, + }, + ] satisfies NoteUpdateWorkflowNoteInfo[]; + harness.deps.getConfig = () => ({ + fields: { + sentence: 'Sentence', + image: 'Picture', + miscInfo: 'MiscInfo', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'avif', + }, + behavior: {}, + }); + harness.deps.getCurrentSubtitleText = () => 'current primary line'; + harness.deps.getCurrentSubtitleStart = () => 20; + harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio'; + harness.deps.generateAudio = async (context?: SubtitleMiningContext) => { + audioContext = context ?? null; + return Buffer.from('audio'); + }; + harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => { + imageContext = context ?? null; + return Buffer.from('image'); + }; + harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => { + miscInfoStartTime = startTimeSeconds; + return `start:${startTimeSeconds}`; + }; + ( + harness.deps as NoteUpdateWorkflowDeps & { + consumeSubtitleMiningContext: () => typeof sidebarContext | null; + } + ).consumeSubtitleMiningContext = () => sidebarContext; + + await harness.workflow.execute(42); + + assert.equal(harness.updates.length, 1); + assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line'); + assert.deepEqual(audioContext, sidebarContext); + assert.deepEqual(imageContext, sidebarContext); + assert.equal(miscInfoStartTime, 10); +}); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index 26613ffa..c0511dc3 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -1,5 +1,6 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { getPreferredWordValueFromExtractedFields } from '../anki-field-config'; +import type { SubtitleMiningContext } from '../types/subtitle'; export interface NoteUpdateWorkflowNoteInfo { noteId: number; @@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps { getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; generateAudioFilename: () => string; - generateAudio: () => Promise; + generateAudio: (context?: SubtitleMiningContext) => Promise; generateImageFilename: () => string; - generateImage: (animatedLeadInSeconds?: number) => Promise; + generateImage: ( + animatedLeadInSeconds?: number, + context?: SubtitleMiningContext, + ) => Promise; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + consumeSubtitleMiningContext?: () => SubtitleMiningContext | null; addConfiguredTagsToNote: (noteId: number) => Promise; showNotification: (noteId: number, label: string | number) => Promise; showOsdNotification: (message: string) => void; @@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps { logError: (message: string, ...args: unknown[]) => void; } +function normalizeSubtitleContextText(text: string): string { + return text + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function hasUsableSubtitleContextTiming(context: SubtitleMiningContext): boolean { + return ( + Number.isFinite(context.startTime) && + Number.isFinite(context.endTime) && + context.endTime > context.startTime + ); +} + +function subtitleContextMatchesSentence(contextText: string, noteSentence: string): boolean { + const normalizedContext = normalizeSubtitleContextText(contextText); + const normalizedSentence = normalizeSubtitleContextText(noteSentence); + if (!normalizedContext || !normalizedSentence) { + return false; + } + return ( + normalizedContext === normalizedSentence || + normalizedContext.includes(normalizedSentence) || + normalizedSentence.includes(normalizedContext) + ); +} + export class NoteUpdateWorkflow { constructor(private readonly deps: NoteUpdateWorkflowDeps) {} + private consumeMatchingSubtitleMiningContext( + fields: Record, + sentenceField: string, + configuredSentenceField?: string, + ): SubtitleMiningContext | null { + const context = this.deps.consumeSubtitleMiningContext?.() ?? null; + if (!context || !hasUsableSubtitleContextTiming(context)) { + return null; + } + + const candidateFields = [ + sentenceField, + configuredSentenceField, + DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence, + ]; + const noteSentence = candidateFields + .map((fieldName) => (fieldName ? fields[fieldName.toLowerCase()] : undefined)) + .find((value): value is string => typeof value === 'string' && value.trim().length > 0); + + if (!noteSentence || subtitleContextMatchesSentence(context.text, noteSentence)) { + return context; + } + return null; + } + async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise { this.deps.beginUpdateProgress('Updating card'); try { @@ -121,8 +179,13 @@ export class NoteUpdateWorkflow { let updatePerformed = false; let miscInfoFilename: string | null = null; const sentenceField = sentenceCardConfig.sentenceField; + const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext( + fields, + sentenceField, + config.fields?.sentence, + ); - const currentSubtitleText = this.deps.getCurrentSubtitleText(); + const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText(); if (sentenceField && currentSubtitleText) { const processedSentence = this.deps.processSentence(currentSubtitleText, fields); updatedFields[sentenceField] = processedSentence; @@ -132,7 +195,7 @@ export class NoteUpdateWorkflow { if (config.media?.generateAudio) { try { const audioFilename = this.deps.generateAudioFilename(); - const audioBuffer = await this.deps.generateAudio(); + const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined); if (audioBuffer) { await this.deps.client.storeMediaFile(audioFilename, audioBuffer); @@ -158,7 +221,10 @@ export class NoteUpdateWorkflow { try { const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo); const imageFilename = this.deps.generateImageFilename(); - const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds); + const imageBuffer = await this.deps.generateImage( + animatedLeadInSeconds, + subtitleMiningContext ?? undefined, + ); if (imageBuffer) { await this.deps.client.storeMediaFile(imageFilename, imageBuffer); @@ -189,7 +255,7 @@ export class NoteUpdateWorkflow { if (config.fields?.miscInfo) { const miscInfo = this.deps.formatMiscInfoPattern( miscInfoFilename || '', - this.deps.getCurrentSubtitleStart(), + subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(), ); const miscInfoField = this.deps.resolveConfiguredFieldName( noteInfo, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index a7b5f698..8bf7de34 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -104,6 +104,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.subtitleStyle.preserveLineBreaks, false); assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true); assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true); + assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true); assert.equal(config.subtitleSidebar.enabled, true); assert.equal(config.subtitleSidebar.pauseVideoOnHover, true); assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6'); @@ -545,6 +546,44 @@ test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid val ); }); +test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => { + const validDir = makeTempDir(); + fs.writeFileSync( + path.join(validDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "primaryVisibleOnYomitanPopup": false + } + }`, + 'utf-8', + ); + + const validService = new ConfigService(validDir); + assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false); + + const invalidDir = makeTempDir(); + fs.writeFileSync( + path.join(invalidDir, 'config.jsonc'), + `{ + "subtitleStyle": { + "primaryVisibleOnYomitanPopup": "yes" + } + }`, + 'utf-8', + ); + + const invalidService = new ConfigService(invalidDir); + assert.equal( + invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, + DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup, + ); + assert.ok( + invalidService + .getWarnings() + .some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'), + ); +}); + test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => { const validDir = makeTempDir(); fs.writeFileSync( diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index b3392fbc..1c9ffe36 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick { + const valid = createResolveContext({ + subtitleStyle: { + primaryVisibleOnYomitanPopup: false, + }, + }); + applySubtitleDomainConfig(valid.context); + assert.equal(valid.context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, false); + + const { context, warnings } = createResolveContext({ + subtitleStyle: { + primaryVisibleOnYomitanPopup: 'invalid' as unknown as boolean, + }, + }); + + applySubtitleDomainConfig(context); + + assert.equal(context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, true); + assert.ok( + warnings.some( + (warning) => + warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup' && + warning.message === 'Expected boolean.', + ), + ); +}); + test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => { const valid = createResolveContext({ subtitleStyle: { diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 720eb19b..e08c8f3a 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -15,6 +15,8 @@ test('settings registry splits viewing into appearance and behavior categories', assert.equal(field('subtitleStyle.fontSize').category, 'appearance'); assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior'); assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior'); + assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior'); + assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior'); assert.equal(field('secondarySub.defaultMode').category, 'behavior'); assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position'); assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode'); @@ -28,7 +30,14 @@ test('settings registry splits viewing into appearance and behavior categories', assert.equal(field('mpv.profile').section, 'mpv Playback'); assert.ok( fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') < - fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), + fields.findIndex( + (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', + ), + ); + assert.ok( + fields.findIndex( + (candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup', + ) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'), ); }); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index a819bb4c..5e679813 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -168,6 +168,7 @@ const PATH_ORDER = new Map( 'subtitleStyle.hoverTokenBackgroundColor', 'subtitleStyle.css', 'subtitleStyle.primaryDefaultMode', + 'subtitleStyle.primaryVisibleOnYomitanPopup', 'subtitleStyle.secondary.fontColor', 'subtitleStyle.secondary.backgroundColor', 'subtitleStyle.secondary.css', @@ -218,6 +219,7 @@ const LABEL_OVERRIDES: Record = { 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', + 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode', 'subtitleStyle.css': 'CSS Declarations', @@ -251,6 +253,8 @@ const DESCRIPTION_OVERRIDES: Record = { 'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.', 'subtitleSidebar.css': 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', + 'subtitleStyle.primaryVisibleOnYomitanPopup': + 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.', 'websocket.enabled': 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', 'discordPresence.updateIntervalMs': @@ -359,7 +363,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s if (path.startsWith('subtitleStyle.secondary.')) { return { category: 'appearance', section: 'Secondary Subtitle Appearance' }; } - if (path === 'subtitleStyle.primaryDefaultMode') { + if ( + path === 'subtitleStyle.primaryDefaultMode' || + path === 'subtitleStyle.primaryVisibleOnYomitanPopup' + ) { return { category: 'behavior', section: 'Subtitle Behavior' }; } if (path.startsWith('subtitleStyle.')) { @@ -603,6 +610,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean { } function fieldTypeRank(field: ConfigSettingsField): number { + if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2; if (field.control !== 'boolean') return 2; return isFeatureToggle(field) ? 0 : 1; } diff --git a/src/core/services/field-grouping-overlay.test.ts b/src/core/services/field-grouping-overlay.test.ts index 0572d798..0b5c4614 100644 --- a/src/core/services/field-grouping-overlay.test.ts +++ b/src/core/services/field-grouping-overlay.test.ts @@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay assert.equal(visible, false); assert.deepEqual(visibilityTransitions, [true, false]); }); + +async function settleWithinMicrotasks( + promise: Promise, + attempts = 10, +): Promise { + let settled = false; + let settledValue: T | undefined; + void promise.then((value) => { + settled = true; + settledValue = value; + }); + + for (let i = 0; i < attempts; i += 1) { + await Promise.resolve(); + if (settled) { + return settledValue as T; + } + } + return 'timeout'; +} + +test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => { + let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null; + const sends: Array<{ + channel: string; + payload: unknown; + restoreOnModalClose?: string; + preferModalWindow?: boolean; + }> = []; + const waitCalls: Array<{ modal: string; timeoutMs: number }> = []; + const warnings: string[] = []; + const closed: string[] = []; + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout; + + try { + const runtime = createFieldGroupingOverlayRuntime<'kiku'>({ + getMainWindow: () => null, + getVisibleOverlayVisible: () => true, + setVisibleOverlayVisible: () => {}, + getResolver: () => resolver, + setResolver: (nextResolver) => { + resolver = nextResolver; + }, + getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(), + sendToVisibleOverlay: (channel, payload, runtimeOptions) => { + sends.push({ + channel, + payload, + restoreOnModalClose: runtimeOptions?.restoreOnModalClose, + preferModalWindow: runtimeOptions?.preferModalWindow, + }); + return true; + }, + waitForModalOpen: async (modal, timeoutMs) => { + waitCalls.push({ modal, timeoutMs }); + return false; + }, + handleOverlayModalClosed: (modal) => { + closed.push(modal); + }, + logWarn: (message) => { + warnings.push(message); + }, + }); + + const request = { + original: { + noteId: 1, + expression: 'a', + sentencePreview: 'a', + hasAudio: false, + hasImage: false, + isOriginal: true, + }, + duplicate: { + noteId: 2, + expression: 'b', + sentencePreview: 'b', + hasAudio: false, + hasImage: false, + isOriginal: false, + }, + }; + const pendingChoice = runtime.createFieldGroupingCallback()(request); + const result = await settleWithinMicrotasks(pendingChoice); + + assert.notEqual(result, 'timeout'); + assert.deepEqual(result, { + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + assert.equal(resolver, null); + assert.deepEqual( + sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({ + channel, + restoreOnModalClose, + preferModalWindow, + })), + [ + { + channel: 'kiku:field-grouping-request', + restoreOnModalClose: 'kiku', + preferModalWindow: true, + }, + { + channel: 'kiku:field-grouping-request', + restoreOnModalClose: 'kiku', + preferModalWindow: true, + }, + ], + ); + assert.deepEqual(waitCalls, [ + { modal: 'kiku', timeoutMs: 1500 }, + { modal: 'kiku', timeoutMs: 1500 }, + ]); + assert.deepEqual(warnings, [ + 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + ]); + assert.deepEqual(closed, ['kiku']); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +}); diff --git a/src/core/services/field-grouping-overlay.ts b/src/core/services/field-grouping-overlay.ts index 60b9b128..2e17aed6 100644 --- a/src/core/services/field-grouping-overlay.ts +++ b/src/core/services/field-grouping-overlay.ts @@ -8,6 +8,10 @@ interface WindowLike { }; } +const KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS = 1500; +const KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING = + 'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.'; + export interface FieldGroupingOverlayRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisible: () => boolean; @@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions { getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; getRestoreVisibleOverlayOnModalClose: () => Set; + waitForModalOpen?: (modal: T, timeoutMs: number) => Promise; + handleOverlayModalClosed?: (modal: T) => void; + logWarn?: (message: string) => void; sendToVisibleOverlay?: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; } @@ -28,7 +35,7 @@ export function createFieldGroupingOverlayRuntime( sendToVisibleOverlay: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; createFieldGroupingCallback: () => ( data: KikuFieldGroupingRequestData, @@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime( const sendToVisibleOverlay = ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ): boolean => { if (options.sendToVisibleOverlay) { const wasVisible = options.getVisibleOverlayVisible(); @@ -58,6 +65,43 @@ export function createFieldGroupingOverlayRuntime( }); }; + const sendKikuFieldGroupingRequest = async ( + data: KikuFieldGroupingRequestData, + ): Promise => { + const kikuModal = 'kiku' as T; + const sendOpen = (): boolean => + sendToVisibleOverlay('kiku:field-grouping-request', data, { + restoreOnModalClose: kikuModal, + preferModalWindow: true, + }); + + if (!options.waitForModalOpen) { + return sendOpen(); + } + + if (!sendOpen()) { + return false; + } + if (await options.waitForModalOpen(kikuModal, KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS)) { + return true; + } + + options.logWarn?.(KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING); + if (!sendOpen()) { + options.handleOverlayModalClosed?.(kikuModal); + return false; + } + + const opened = await options.waitForModalOpen( + kikuModal, + KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS, + ); + if (!opened) { + options.handleOverlayModalClosed?.(kikuModal); + } + return opened; + }; + const createFieldGroupingCallback = (): (( data: KikuFieldGroupingRequestData, ) => Promise) => { @@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime( getResolver: options.getResolver, setResolver: options.setResolver, sendToVisibleOverlay, + sendKikuFieldGroupingRequest, }); }; diff --git a/src/core/services/field-grouping.ts b/src/core/services/field-grouping.ts index 9ed3cc34..6585bcd5 100644 --- a/src/core/services/field-grouping.ts +++ b/src/core/services/field-grouping.ts @@ -5,7 +5,7 @@ export function createFieldGroupingCallback(options: { setVisibleOverlayVisible: (visible: boolean) => void; getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null; setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void; - sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean; + sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise; }): (data: KikuFieldGroupingRequestData) => Promise { return async (data: KikuFieldGroupingRequestData): Promise => { return new Promise((resolve) => { @@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: { const previousVisibleOverlay = options.getVisibleOverlayVisible(); let settled = false; + let timeout: ReturnType | null = null; const finish = (choice: KikuFieldGroupingChoice): void => { if (settled) return; settled = true; + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } if (options.getResolver() === finish) { options.setResolver(null); } @@ -36,25 +41,38 @@ export function createFieldGroupingCallback(options: { }; options.setResolver(finish); - if (!options.sendRequestToVisibleOverlay(data)) { - finish({ - keepNoteId: 0, - deleteNoteId: 0, - deleteDuplicate: true, - cancelled: true, - }); - return; - } - setTimeout(() => { - if (!settled) { + void Promise.resolve(options.sendRequestToVisibleOverlay(data)).then( + (sent) => { + if (settled) return; + if (!sent) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + return; + } + timeout = setTimeout(() => { + if (!settled) { + finish({ + keepNoteId: 0, + deleteNoteId: 0, + deleteDuplicate: true, + cancelled: true, + }); + } + }, 90000); + }, + () => { finish({ keepNoteId: 0, deleteNoteId: 0, deleteDuplicate: true, cancelled: true, }); - } - }, 90000); + }, + ); }); }; } diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index f9429a63..17a5afde 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -630,6 +630,83 @@ 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 records yomitan lookup when subtitle context recording fails', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: string[] = []; + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + const deps = createRegisterIpcDeps({ + immersionTracker: createFakeImmersionTracker({ + recordYomitanLookup: () => { + calls.push('lookup'); + }, + }), + }) as IpcServiceDeps & { + recordSubtitleMiningContext: (context: unknown | null) => void; + }; + deps.recordSubtitleMiningContext = () => { + throw new Error('context write failed'); + }; + + try { + registerIpcHandlers(deps, registrar); + + const handler = handlers.on.get(IPC_CHANNELS.command.recordYomitanLookup); + assert.equal(typeof handler, 'function'); + + assert.doesNotThrow(() => { + handler?.({}, { source: 'subtitle-sidebar', text: 'line', startTime: 1, endTime: 2 }); + }); + + assert.deepEqual(calls, ['lookup']); + assert.equal(warnings.length, 1); + assert.equal(warnings[0]?.[0], 'Failed to record subtitle mining context:'); + assert.equal(warnings[0]?.[1], 'context write failed'); + } finally { + console.warn = originalWarn; + } +}); + test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers(createRegisterIpcDeps(), registrar); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ed66f003..466f2714 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -9,6 +9,7 @@ import type { ResolvedControllerConfig, RuntimeOptionId, RuntimeOptionValue, + SubtitleMiningContext, SubtitleSidebarSnapshot, SubtitlePosition, SubsyncManualRunRequest, @@ -95,6 +96,7 @@ export interface IpcServiceDeps { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; + recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; setCharacterDictionarySelection?: ( mediaId: number, @@ -175,6 +177,43 @@ interface IpcMainRegistrar { handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void; } +function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | null { + if (!payload || typeof payload !== 'object') { + return null; + } + + const record = payload as Record; + const source = record.source; + const text = record.text; + const startTime = record.startTime; + const endTime = record.endTime; + const capturedAtMs = record.capturedAtMs; + + if ( + source !== 'subtitle-sidebar' || + typeof text !== 'string' || + text.trim().length === 0 || + typeof startTime !== 'number' || + typeof endTime !== 'number' || + !Number.isFinite(startTime) || + !Number.isFinite(endTime) || + endTime <= startTime + ) { + return null; + } + + const parsed: SubtitleMiningContext = { + source: 'subtitle-sidebar', + text, + startTime, + endTime, + }; + if (typeof capturedAtMs === 'number' && Number.isFinite(capturedAtMs)) { + parsed.capturedAtMs = capturedAtMs; + } + return parsed; +} + export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; @@ -230,6 +269,7 @@ export interface IpcDepsRuntimeOptions { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; + recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; setCharacterDictionarySelection?: ( mediaId: number, @@ -257,6 +297,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService onOverlayModalOpened: options.onOverlayModalOpened, onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, openYomitanSettings: options.openYomitanSettings, + recordSubtitleMiningContext: options.recordSubtitleMiningContext, quitApp: options.quitApp, toggleDevTools: () => { const mainWindow = options.getMainWindow(); @@ -423,7 +464,15 @@ 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) => { + try { + deps.recordSubtitleMiningContext?.(parseSubtitleMiningContext(payload)); + } catch (error) { + console.warn( + 'Failed to record subtitle mining context:', + error instanceof Error ? error.message : String(error), + ); + } deps.immersionTracker?.recordYomitanLookup(); }); diff --git a/src/core/services/mining.test.ts b/src/core/services/mining.test.ts index 431a1332..d98e2060 100644 --- a/src/core/services/mining.test.ts +++ b/src/core/services/mining.test.ts @@ -124,6 +124,70 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async () ]); }); +test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => { + const created: Array<{ sentence: string; secondarySub?: string }> = []; + const requestedProperties: string[] = []; + + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { + created.push({ sentence, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: '日本語字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: '日本語字幕', + requestProperty: async (name: string) => { + requestedProperties.push(name); + return name === 'secondary-sub-text' ? 'English subtitle' : null; + }, + }, + showMpvOsd: () => {}, + }); + + assert.deepEqual(requestedProperties, ['secondary-sub-text']); + assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]); +}); + +test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => { + const created: Array<{ sentence: string; secondarySub?: string }> = []; + + await mineSentenceCard({ + ankiIntegration: { + updateLastAddedFromClipboard: async () => {}, + triggerFieldGroupingForLastAddedCard: async () => {}, + markLastCardAsAudioCard: async () => {}, + createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => { + created.push({ sentence, secondarySub }); + return true; + }, + }, + mpvClient: { + connected: true, + currentSubText: '日本語字幕', + currentSubStart: 10, + currentSubEnd: 12, + currentSecondarySubText: 'stale cached subtitle', + requestProperty: async (name: string) => { + if (name === 'secondary-sub-text') { + return ''; + } + return null; + }, + }, + showMpvOsd: () => {}, + }); + + assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: undefined }]); +}); + test('handleMultiCopyDigit copies available history and reports truncation', () => { const osd: string[] = []; const copied: string[] = []; diff --git a/src/core/services/mining.ts b/src/core/services/mining.ts index 3baa7275..4871df7f 100644 --- a/src/core/services/mining.ts +++ b/src/core/services/mining.ts @@ -25,6 +25,7 @@ interface MpvClientLike { currentSubStart: number; currentSubEnd: number; currentSecondarySubText?: string; + requestProperty?: (name: string) => Promise; } export function handleMultiCopyDigit( @@ -95,6 +96,32 @@ function getSecondarySubTextForMinedBlocks( return getCurrentSecondarySubText(); } +function normalizeSecondarySubText(text: unknown, primaryText: string): string | undefined { + if (typeof text !== 'string') { + return undefined; + } + const trimmed = text.trim(); + if (!trimmed || trimmed === primaryText.trim()) { + return undefined; + } + return trimmed; +} + +async function getCurrentSecondarySubTextForSentenceCard( + mpvClient: MpvClientLike, +): Promise { + const primaryText = mpvClient.currentSubText; + if (mpvClient.requestProperty) { + try { + const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text'); + return normalizeSecondarySubText(latestSecondaryText, primaryText); + } catch { + // Fall back to the cached secondary subtitle below. + } + } + return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText); +} + export async function updateLastCardFromClipboard(deps: { ankiIntegration: AnkiIntegrationLike | null; readClipboardText: () => string; @@ -141,11 +168,12 @@ export async function mineSentenceCard(deps: { return false; } + const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient); return await anki.createSentenceCard( mpvClient.currentSubText, mpvClient.currentSubStart, mpvClient.currentSubEnd, - mpvClient.currentSecondarySubText || undefined, + secondarySubText, ); } diff --git a/src/core/services/overlay-bridge.ts b/src/core/services/overlay-bridge.ts index 8f26db88..fd977aa0 100644 --- a/src/core/services/overlay-bridge.ts +++ b/src/core/services/overlay-bridge.ts @@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime(options: { sendToVisibleOverlay: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: T }, + runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean }, ) => boolean; + sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise; }): (data: KikuFieldGroupingRequestData) => Promise { return createFieldGroupingCallback({ getVisibleOverlayVisible: options.getVisibleOverlayVisible, @@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime(options: { getResolver: options.getResolver, setResolver: options.setResolver, sendRequestToVisibleOverlay: (data) => - options.sendToVisibleOverlay('kiku:field-grouping-request', data, { - restoreOnModalClose: 'kiku' as T, - }), + options.sendKikuFieldGroupingRequest + ? options.sendKikuFieldGroupingRequest(data) + : options.sendToVisibleOverlay('kiku:field-grouping-request', data, { + restoreOnModalClose: 'kiku' as T, + }), }); } diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index aee66806..98bb8398 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -25,6 +25,7 @@ interface YomitanTokenInput { surface: string; reading?: string; headword?: string; + frequencyRank?: number; isNameMatch?: boolean; wordClasses?: string[]; } @@ -57,6 +58,7 @@ function makeDepsFromYomitanTokens( startPos, endPos, isNameMatch: token.isNameMatch ?? false, + frequencyRank: token.frequencyRank, wordClasses: token.wordClasses, }; }); @@ -4279,6 +4281,64 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin assert.equal(result.tokens?.[0]?.frequencyRank, 5468); }); +test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds', async () => { + const result = await tokenizeSubtitle( + '目の前', + makeDepsFromYomitanTokens( + [{ surface: '目の前', reading: 'めのまえ', headword: '目の前', frequencyRank: 581 }], + { + getFrequencyDictionaryEnabled: () => true, + tokenizeWithMecab: async () => [ + { + headword: '目', + surface: '目', + reading: 'メ', + startPos: 0, + endPos: 1, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '一般', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'の', + surface: 'の', + reading: 'ノ', + startPos: 1, + endPos: 2, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '連体化', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: '前', + surface: '前', + reading: 'マエ', + startPos: 2, + endPos: 3, + partOfSpeech: PartOfSpeech.noun, + pos1: '名詞', + pos2: '副詞可能', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + }, + ), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, '目の前'); + assert.equal(result.tokens?.[0]?.pos1, '名詞|助詞'); + assert.equal(result.tokens?.[0]?.frequencyRank, 581); +}); + test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => { const result = await tokenizeSubtitle( '第二走者', diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index 6098b881..3189e98f 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet exclusions.has(part)); + + return parts.every((part) => exclusions.has(part)); } function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet { @@ -227,6 +226,10 @@ function isFrequencyExcludedByPos( return true; } + if (isKanaOnlyMixedFunctionContentToken(token, pos1Exclusions)) { + return true; + } + const normalizedPos1 = normalizePos1Tag(token.pos1); const hasPos1 = normalizedPos1.length > 0; const normalizedPos2 = normalizePos2Tag(token.pos2); @@ -564,6 +567,35 @@ function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean { return chars.length === 1 && isKanaChar(chars[0]!); } +function isKanaOnlyText(text: string | undefined): boolean { + if (typeof text !== 'string') { + return false; + } + + const normalized = text.trim(); + if (!normalized) { + return false; + } + + return [...normalized].every(isKanaChar); +} + +function isKanaOnlyMixedFunctionContentToken( + token: MergedToken, + pos1Exclusions: ReadonlySet, +): boolean { + if (!isKanaOnlyText(token.surface)) { + return false; + } + + const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1)); + return ( + pos1Parts.length >= 2 && + pos1Parts.some((part) => pos1Exclusions.has(part)) && + pos1Parts.some((part) => !pos1Exclusions.has(part)) + ); +} + function isJlptEligibleToken(token: MergedToken): boolean { if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) { return false; diff --git a/src/main.ts b/src/main.ts index 3d02a9c0..85505926 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ import type { SecondarySubMode, SubtitleCue, SubtitleData, + SubtitleMiningContext, SubtitlePosition, UpdateChannel, WindowGeometry, @@ -730,8 +731,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug') const texthookerService = new Texthooker(() => { const config = getResolvedConfig(); const characterDictionaryEnabled = - config.subtitleStyle.nameMatchEnabled && - yomitanProfilePolicy.isCharacterDictionaryEnabled(); + config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownWordColoringEnabled = getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', config.ankiConnect.knownWords.highlightEnabled, @@ -908,6 +908,7 @@ const { appState, appLifecycleApp, } = bootServices; +let pendingSubtitleMiningContext: SubtitleMiningContext | null = null; const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG); notifyAnilistTokenStoreWarning = (message: string) => { logger.warn(`[AniList] ${message}`); @@ -2181,6 +2182,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime setFieldGroupingResolver(resolver), getRestoreVisibleOverlayOnModalClose: () => overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal), + logWarn: (message) => logger.warn(message), sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions), })(), @@ -4190,6 +4194,14 @@ const immersionTrackerStartupMainDeps: Parameters< const createImmersionTrackerStartup = createImmersionTrackerStartupHandler( createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(), ); +const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => { + pendingSubtitleMiningContext = context; +}; +const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => { + const context = pendingSubtitleMiningContext; + pendingSubtitleMiningContext = null; + return context; +}; const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => { ensureImmersionTrackerStarted(); appState.immersionTracker?.recordCardsMined(count, noteIds); @@ -5153,6 +5165,7 @@ function initializeOverlayRuntime(): void { appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( refreshCurrentSubtitleAfterKnownWordUpdate, ); + appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); syncOverlayMpvSubtitleSuppression(); } @@ -5948,6 +5961,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), + recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context), quitApp: () => requestAppQuit(), toggleVisibleOverlay: () => toggleVisibleOverlay(), tokenizeCurrentSubtitle: async () => { @@ -6198,6 +6212,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( refreshCurrentSubtitleAfterKnownWordUpdate, ); + appState.ankiIntegration?.setSubtitleMiningContextConsumer( + consumePendingSubtitleMiningContext, + ); }, getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), showDesktopNotification, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index d1959a62..a1a36866 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -96,6 +96,7 @@ export interface MainIpcRuntimeServiceDepsParams { getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark']; + recordSubtitleMiningContext?: IpcDepsRuntimeOptions['recordSubtitleMiningContext']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot']; @@ -273,6 +274,7 @@ export function createMainIpcRuntimeServiceDeps( getAnilistQueueStatus: params.getAnilistQueueStatus, retryAnilistQueueNow: params.retryAnilistQueueNow, runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark, + recordSubtitleMiningContext: params.recordSubtitleMiningContext, getCharacterDictionarySelection: params.getCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection, getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 5d2f56a0..ad7a52f9 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -804,6 +804,28 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => { assert.equal(await pending, true); }); +test('waitForModalOpen resolves true when modal acknowledgement arrives before waiter registration', async () => { + const modalWindow = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow( + 'kiku:field-grouping-request', + {}, + { + restoreOnModalClose: 'kiku', + }, + ); + runtime.notifyOverlayModalOpened('kiku'); + + assert.equal(await runtime.waitForModalOpen('kiku', 5), true); +}); + test('waitForModalOpen resolves false on timeout', async () => { const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 47f59912..b9b2401d 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService( ): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); const modalOpenWaiters = new Map void>>(); + const openedModals = new Set(); let modalActive = false; let mainWindowMousePassthroughForcedByModal = false; let mainWindowHiddenByModal = false; @@ -375,6 +376,7 @@ export function createOverlayModalRuntimeService( }; const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { + openedModals.delete(modal); if (!restoreVisibleOverlayOnModalClose.has(modal)) return; restoreVisibleOverlayOnModalClose.delete(modal); const modalWindow = deps.getModalWindow(); @@ -392,6 +394,7 @@ export function createOverlayModalRuntimeService( const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + openedModals.add(modal); const waiters = modalOpenWaiters.get(modal) ?? []; modalOpenWaiters.delete(modal); for (const resolve of waiters) { @@ -420,6 +423,10 @@ export function createOverlayModalRuntimeService( const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise => await new Promise((resolve) => { + if (openedModals.has(modal)) { + resolve(true); + return; + } const waiters = modalOpenWaiters.get(modal) ?? []; const finish = (opened: boolean): void => { clearTimeout(timeout); diff --git a/src/main/runtime/field-grouping-overlay-main-deps.ts b/src/main/runtime/field-grouping-overlay-main-deps.ts index 1dbf8a3c..57a97436 100644 --- a/src/main/runtime/field-grouping-overlay-main-deps.ts +++ b/src/main/runtime/field-grouping-overlay-main-deps.ts @@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps = Omit< sendToActiveOverlayWindow: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: TModal }, + runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean }, ) => boolean; }; @@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions), }); } diff --git a/src/main/runtime/log-export.ts b/src/main/runtime/log-export.ts index b1c06e14..302857d7 100644 --- a/src/main/runtime/log-export.ts +++ b/src/main/runtime/log-export.ts @@ -11,6 +11,7 @@ type LogCandidate = { mtimeMs: number; mtimeDateKey: string; fileDateKey: string | null; + fileWeekKey: string | null; }; export type ExportLogsResult = { @@ -38,10 +39,21 @@ function localDateKey(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } +function localWeekKey(date: Date): string { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const dayOfYear = + Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)) + 1; + return `${date.getFullYear()}-W${pad(Math.max(1, Math.ceil(dayOfYear / 7)))}`; +} + function filenameDateKey(fileName: string): string | null { return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null; } +function filenameWeekKey(fileName: string): string | null { + return fileName.match(/\d{4}-W\d{2}/)?.[0] ?? null; +} + function fileKind(fileName: string): string { const match = fileName.match(/^([A-Za-z0-9_-]+)-/); return match?.[1] ?? fileName; @@ -84,6 +96,7 @@ function buildCandidate(logsDir: string, entry: string): LogCandidate | null { mtimeMs: stats.mtimeMs, mtimeDateKey: localDateKey(stats.mtime), fileDateKey: filenameDateKey(entry), + fileWeekKey: filenameWeekKey(entry), }; } @@ -117,6 +130,14 @@ function candidateFreshnessMs(candidate: LogCandidate): number { if (candidate.fileDateKey) { return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`); } + if (candidate.fileWeekKey) { + const match = candidate.fileWeekKey.match(/^(\d{4})-W(\d{2})$/); + if (match) { + const year = Number(match[1]); + const week = Number(match[2]); + return Date.UTC(year, 0, week * 7, 23, 59, 59, 999); + } + } return candidate.mtimeMs; } @@ -130,6 +151,12 @@ function selectLogCandidates( return { mode: 'current-day', selected: currentDated }; } + const currentWeek = localWeekKey(now); + const currentWeekly = candidates.filter((candidate) => candidate.fileWeekKey === currentWeek); + if (currentWeekly.length > 0) { + return { mode: 'current-day', selected: currentWeekly }; + } + const currentUndated = candidates.filter( (candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today, ); diff --git a/src/preload.ts b/src/preload.ts index e400a114..aa081f87 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -55,6 +55,7 @@ import type { ControllerPreferenceUpdate, ResolvedControllerConfig, SessionNumericSelectionStartPayload, + SubtitleMiningContext, YoutubePickerOpenPayload, YoutubePickerResolveRequest, YoutubePickerResolveResult, @@ -262,8 +263,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); }, - recordYomitanLookup: () => { - ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup); + recordYomitanLookup: (context?: SubtitleMiningContext | null) => { + ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null); }, getSubtitlePosition: (): Promise => diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index f6e64ff3..b8369225 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o } }); +test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => { + const ctx = createMouseTestContext(); + (ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true; + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + const bodyClassList = createClassList(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + body: { classList: bodyClassList }, + querySelectorAll: () => [], + querySelector: () => null, + visibilityState: 'visible', + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupYomitanObserver(); + + for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) { + listener(); + } + assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true); + + for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index a7063388..e15b4fa1 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -5,6 +5,7 @@ import { YOMITAN_POPUP_MOUSE_ENTER_EVENT, YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_SHOWN_EVENT, + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; @@ -44,10 +45,21 @@ export function createMouseHandlers( return typeof document !== 'undefined' && isYomitanPopupVisible(document); } + function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void { + if (typeof document === 'undefined') { + return; + } + document.body?.classList?.toggle( + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, + popupVisible && ctx.state.primaryVisibleOnYomitanPopup, + ); + } + function syncPopupVisibilityState(assumeVisible = false): boolean { const popupVisible = assumeVisible || getPopupVisibilityFromDom(); yomitanPopupVisible = popupVisible; ctx.state.yomitanPopupVisible = popupVisible; + syncPrimaryVisibleOnYomitanPopupClass(popupVisible); return popupVisible; } @@ -293,6 +305,7 @@ export function createMouseHandlers( yomitanPopupVisible = false; ctx.state.yomitanPopupVisible = false; + syncPrimaryVisibleOnYomitanPopupClass(false); popupPauseRequestId += 1; maybeResumeYomitanPopupPause(); maybeResumeHoverPause(); diff --git a/src/renderer/modals/subtitle-sidebar.test.ts b/src/renderer/modals/subtitle-sidebar.test.ts index 721c3463..51ded15a 100644 --- a/src/renderer/modals/subtitle-sidebar.test.ts +++ b/src/renderer/modals/subtitle-sidebar.test.ts @@ -113,6 +113,88 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0); }); +test('subtitle sidebar mining context resolves selected row cue timing', () => { + const globals = globalThis as typeof globalThis & { + Element?: unknown; + Node?: unknown; + window?: unknown; + }; + const previousElement = globals.Element; + const previousNode = globals.Node; + const previousWindow = globals.window; + + class FakeNode { + parentElement: FakeElement | null = null; + } + class FakeElement extends FakeNode { + dataset: Record = {}; + + closest(selector: string) { + return selector === '.subtitle-sidebar-item' ? this : null; + } + } + + const row = new FakeElement(); + row.dataset.index = '1'; + const textNode = new FakeNode(); + textNode.parentElement = row; + + Object.defineProperty(globalThis, 'Node', { configurable: true, value: FakeNode }); + Object.defineProperty(globalThis, 'Element', { configurable: true, value: FakeElement }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + getSelection: () => ({ anchorNode: textNode, focusNode: null }), + }, + }); + + try { + const state = createRendererState(); + state.subtitleSidebarModalOpen = true; + state.subtitleSidebarCues = [ + { startTime: 1, endTime: 2, text: 'current line' }, + { startTime: 3, endTime: 5, text: 'sidebar previous line' }, + ]; + const modal = createSubtitleSidebarModal( + { + dom: { + overlay: { classList: createClassList() }, + subtitleSidebarModal: { + classList: createClassList(), + setAttribute: () => {}, + style: { setProperty: () => {} }, + addEventListener: () => {}, + }, + subtitleSidebarContent: { + classList: createClassList(), + getBoundingClientRect: () => ({ width: 420 }), + style: { setProperty: () => {} }, + }, + subtitleSidebarClose: { addEventListener: () => {} }, + subtitleSidebarStatus: { textContent: '' }, + subtitleSidebarList: createListStub(), + }, + state, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + }, + ); + + const context = modal.getSubtitleSidebarMiningContext(); + + assert.equal(context?.source, 'subtitle-sidebar'); + assert.equal(context?.text, 'sidebar previous line'); + assert.equal(context?.startTime, 3); + assert.equal(context?.endTime, 5); + assert.equal(typeof context?.capturedAtMs, 'number'); + } finally { + Object.defineProperty(globalThis, 'Element', { configurable: true, value: previousElement }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + } +}); + test('applySidebarCssDeclarations clears declarations removed by config reload', () => { const removed: string[] = []; const style = { diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index 57e4d6b1..4a9d6065 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -1,4 +1,9 @@ -import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types'; +import type { + SubtitleCue, + SubtitleData, + SubtitleMiningContext, + SubtitleSidebarSnapshot, +} from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { @@ -201,6 +206,7 @@ export function createSubtitleSidebarModal( let subtitleSidebarFocusedWithin = false; let subtitleSidebarYomitanPopupVisible = false; let subtitleSidebarPauseHeldByYomitanPopup = false; + let lastSubtitleSidebarLookupCueIndex = -1; function restoreEmbeddedSidebarPassthrough(): void { syncOverlayMouseIgnoreState(ctx); @@ -213,9 +219,75 @@ export function createSubtitleSidebarModal( function clearSidebarInteractionState(): void { subtitleSidebarHovered = false; subtitleSidebarFocusedWithin = false; + lastSubtitleSidebarLookupCueIndex = -1; syncSidebarInteractionState(); } + function findCueIndexFromNode(node: Node | null): number | null { + if (!node || typeof Element === 'undefined') { + return null; + } + const element = node instanceof Element ? node : node.parentElement; + const row = element?.closest('.subtitle-sidebar-item') ?? null; + if (!row) { + return null; + } + const index = Number.parseInt(row.dataset.index ?? '', 10); + if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) { + return null; + } + return index; + } + + function rememberLookupCueFromTarget(target: EventTarget | null): void { + if (typeof Node === 'undefined') { + return; + } + if (!(target instanceof Node)) { + return; + } + const index = findCueIndexFromNode(target); + if (index === null) { + return; + } + lastSubtitleSidebarLookupCueIndex = index; + } + + function getSubtitleSidebarMiningContext(): SubtitleMiningContext | null { + if (!ctx.state.subtitleSidebarModalOpen) { + return null; + } + + const selection = window.getSelection?.() ?? null; + const selectionIndex = + findCueIndexFromNode(selection?.anchorNode ?? null) ?? + findCueIndexFromNode(selection?.focusNode ?? null); + const index = + selectionIndex ?? + (lastSubtitleSidebarLookupCueIndex >= 0 ? lastSubtitleSidebarLookupCueIndex : null); + if (index === null) { + return null; + } + + const cue = ctx.state.subtitleSidebarCues[index]; + if ( + !cue || + !Number.isFinite(cue.startTime) || + !Number.isFinite(cue.endTime) || + cue.endTime <= cue.startTime + ) { + return null; + } + + return { + source: 'subtitle-sidebar', + text: cue.text, + startTime: cue.startTime, + endTime: cue.endTime, + capturedAtMs: Date.now(), + }; + } + function setStatus(message: string): void { ctx.dom.subtitleSidebarStatus.textContent = message; } @@ -653,6 +725,12 @@ export function createSubtitleSidebarModal( ctx.dom.subtitleSidebarList.addEventListener('wheel', () => { ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS; }); + ctx.dom.subtitleSidebarList.addEventListener('pointerover', (event) => { + rememberLookupCueFromTarget(event.target); + }); + ctx.dom.subtitleSidebarList.addEventListener('focusin', (event) => { + rememberLookupCueFromTarget(event.target); + }); ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => { subtitleSidebarHovered = true; syncSidebarInteractionState(); @@ -677,6 +755,9 @@ export function createSubtitleSidebarModal( }); ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => { subtitleSidebarHovered = false; + if (!subtitleSidebarFocusedWithin) { + lastSubtitleSidebarLookupCueIndex = -1; + } syncSidebarInteractionState(); if (ctx.state.isOverSubtitleSidebar) { restoreEmbeddedSidebarPassthrough(); @@ -700,6 +781,7 @@ export function createSubtitleSidebarModal( } subtitleSidebarFocusedWithin = false; + lastSubtitleSidebarLookupCueIndex = -1; syncSidebarInteractionState(); if (ctx.state.isOverSubtitleSidebar) { restoreEmbeddedSidebarPassthrough(); @@ -736,5 +818,6 @@ export function createSubtitleSidebarModal( }, handleSubtitleUpdated, seekToCue, + getSubtitleSidebarMiningContext, }; } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index a4571839..6ef7e4da 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -580,7 +580,7 @@ registerModalOpenHandlers(); registerKeyboardCommandHandlers(); registerYomitanLookupListener(window, () => { runGuarded('yomitan:lookup', () => { - window.electronAPI.recordYomitanLookup(); + window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext()); }); }); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index b3c17b76..dc639b55 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -114,6 +114,7 @@ export type RendererState = { preserveSubtitleLineBreaks: boolean; autoPauseVideoOnSubtitleHover: boolean; autoPauseVideoOnYomitanPopup: boolean; + primaryVisibleOnYomitanPopup: boolean; frequencyDictionaryEnabled: boolean; frequencyDictionaryTopX: number; frequencyDictionaryMode: 'single' | 'banded'; @@ -225,6 +226,7 @@ export function createRendererState(): RendererState { preserveSubtitleLineBreaks: false, autoPauseVideoOnSubtitleHover: false, autoPauseVideoOnYomitanPopup: false, + primaryVisibleOnYomitanPopup: true, frequencyDictionaryEnabled: false, frequencyDictionaryTopX: 1000, frequencyDictionaryMode: 'single', diff --git a/src/renderer/style.css b/src/renderer/style.css index e4b907b2..cdfcd3e0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer { opacity: 1; } +body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover { + opacity: 1; +} + #subtitleContainer.primary-sub-hidden { display: none; pointer-events: none; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index facbf6fe..fa5cce98 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo ); assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/); + const primaryHoverYomitanPopupVisibleBlock = extractClassBlock( + cssText, + 'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover', + ); + assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/); + const secondaryEmbeddedHoverBlock = extractClassBlock( cssText, 'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover', diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 417aa33c..81b50a3d 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -6,6 +6,7 @@ import type { SubtitleRendererStyleConfig, } from '../types'; import type { RendererContext } from './context'; +import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js'; type FrequencyRenderSettings = { enabled: boolean; @@ -259,6 +260,13 @@ function applySubtitleCssDeclarations( ); } +function syncPrimaryVisibleOnYomitanPopupClass(ctx: RendererContext): void { + document.body?.classList?.toggle( + PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, + ctx.state.yomitanPopupVisible && ctx.state.primaryVisibleOnYomitanPopup, + ); +} + function pickInlineStyleDeclarations( declarations: Record, includedKeys: ReadonlySet, @@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) { ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false; ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false; ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false; + ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true; + syncPrimaryVisibleOnYomitanPopupClass(ctx); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2); ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3); diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 2d8006b6..e3301e5e 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter'; export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave'; export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; +export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup'; export function registerYomitanLookupListener( target: EventTarget = window, diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 0abad13c..b81c0706 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -29,6 +29,7 @@ import type { ResolvedSubtitleSidebarConfig, SecondarySubMode, SubtitleData, + SubtitleMiningContext, SubtitlePosition, SubtitleSidebarSnapshot, SubtitleRendererStyleConfig, @@ -413,7 +414,7 @@ export interface ElectronAPI { onSubtitleAss: (callback: (assText: string) => void) => void; setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void; openYomitanSettings: () => void; - recordYomitanLookup: () => void; + recordYomitanLookup: (context?: SubtitleMiningContext | null) => void; getSubtitlePosition: () => Promise; saveSubtitlePosition: (position: SubtitlePosition) => void; getMecabStatus: () => Promise; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index 0d388137..2fb6c4c9 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -81,6 +81,7 @@ export interface SubtitleStyleConfig { preserveLineBreaks?: boolean; autoPauseVideoOnHover?: boolean; autoPauseVideoOnYomitanPopup?: boolean; + primaryVisibleOnYomitanPopup?: boolean; hoverTokenColor?: string; hoverTokenBackgroundColor?: string; nameMatchEnabled?: boolean; @@ -217,6 +218,14 @@ export interface SubtitleSidebarSnapshot { config: SubtitleSidebarSnapshotConfig; } +export interface SubtitleMiningContext { + source: 'subtitle-sidebar'; + text: string; + startTime: number; + endTime: number; + capturedAtMs?: number; +} + export interface SubtitleHoverTokenPayload { tokenIndex: number | null; }