mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 12:55:20 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
29cb8c7fb4
|
|||
| 1dcfed86ab | |||
| efe50ed1e4 | |||
| 5b44981688 | |||
|
2add95d541
|
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: changed
|
||||
area: docs
|
||||
|
||||
- Documented all config options that were present in `config.example.jsonc` but missing from the configuration reference: `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options (`socketPath`, `backend`, `autoStartSubMiner`, `pauseUntilOverlayReady`, `subminerBinaryPath`, `aniskipEnabled`, `aniskipButtonKey`).
|
||||
- Added a **Playback Startup Flow** diagram to the Architecture page showing how the managed launch (`subminer` CLI, app, Windows shortcut) injects the plugin, establishes the IPC socket, and brings up the overlay via the two convergent triggers.
|
||||
- Added a **Runtime Sockets** section and diagram to the IPC + Runtime Contracts page showing the mpv IPC socket and app control socket topology.
|
||||
- Added cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed Kiku duplicate-card field grouping so local duplicate sentence cards trigger the manual modal or auto merge, modal-open acknowledgement races no longer cancel the flow, and merged card fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: changed
|
||||
area: anki
|
||||
|
||||
- `ankiConnect.nPlusOne.enabled` is no longer implicitly set to `true` when `ankiConnect.knownWords.highlightEnabled` is `true`. Users who rely on known-word highlighting and want N+1 target highlighting must now set `ankiConnect.nPlusOne.enabled: true` explicitly.
|
||||
- Updated known-word cache docs and examples to recommend expression/word fields and removed legacy-option references from user-facing config docs.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: config
|
||||
|
||||
- Added `subtitleStyle.primaryVisibleOnYomitanPopup` to keep hover-mode primary subtitles visible while a Yomitan popup is open.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Sentence cards now refresh the current secondary subtitle before saving, so SelectionText uses the loaded translation instead of repeating the primary subtitle.
|
||||
@@ -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.
|
||||
@@ -89,7 +89,7 @@
|
||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
}, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
"bindings": {
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
@@ -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.
|
||||
@@ -531,7 +532,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -594,9 +595,7 @@
|
||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||
"characterDictionary": {
|
||||
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
|
||||
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
|
||||
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete
|
||||
"profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
|
||||
"collapsibleSections": {
|
||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
||||
|
||||
@@ -37,7 +37,7 @@ In both modes, the enrichment workflow is the same:
|
||||
5. Writes metadata to the miscInfo field.
|
||||
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks` (object map), with `ankiConnect.deck` used as legacy fallback.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
|
||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||
|
||||
|
||||
@@ -269,6 +269,43 @@ For domains migrated to reducer-style transitions (for example AniList token/que
|
||||
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
|
||||
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
|
||||
|
||||
## Playback Startup Flow
|
||||
|
||||
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step — the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
|
||||
|
||||
Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running — or for explicit `--start-overlay` and YouTube flows — the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
|
||||
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef proc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef overlay fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
|
||||
Entry["Managed launch<br/>subminer CLI · app · Windows shortcut"]:::entry
|
||||
Entry --> Cfg["Launcher reads config.jsonc<br/>→ plugin runtime config"]:::extrt
|
||||
Cfg --> Spawn["Spawn mpv<br/>--input-ipc-server=/tmp/subminer-socket<br/>--script=plugin/subminer/main.lua<br/>--script-opts=subminer-… (auto_start, backend, …)"]:::proc
|
||||
Spawn --> Boot["Plugin boot · read_options('subminer')<br/>empty subminer.conf; CLI opts win"]:::extrt
|
||||
Boot --> Sock["mpv IPC socket ready"]:::proc
|
||||
Sock --> Who{"Overlay trigger"}:::decision
|
||||
|
||||
Who -->|"app already running, or<br/>--start-overlay / YouTube"| Attach["Launcher startOverlay()<br/>attach via control socket<br/>plugin auto-start suppressed"]:::proc
|
||||
Who -->|"first launch, auto_start=yes"| Self["Plugin file-loaded hook<br/>polls socket → process.start_overlay()"]:::extrt
|
||||
|
||||
Attach --> AppUp
|
||||
Self --> AppUp
|
||||
|
||||
AppUp["Spawn / attach SubMiner app<br/>--start --managed-playback --socket … --backend …"]:::app
|
||||
AppUp --> Ctrl["App control server up<br/>/tmp/subminer-control-* dedupes a 2nd launch"]:::app
|
||||
Ctrl --> Life["app.whenReady → Program Lifecycle (below)"]:::app
|
||||
Life --> Conn["MpvIpcClient connects to /tmp/subminer-socket"]:::overlay
|
||||
Conn --> Show["Transparent overlay over mpv<br/>Yomitan lookup · mine"]:::overlay
|
||||
```
|
||||
|
||||
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
|
||||
|
||||
## Program Lifecycle
|
||||
|
||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||
|
||||
+250
-259
@@ -21,7 +21,7 @@ For most users, start with this minimal configuration:
|
||||
"deck": "YourDeckName",
|
||||
"knownWords": {
|
||||
"decks": {
|
||||
"YourDeckName": ["Word", "Word Reading", "Expression"]
|
||||
"YourDeckName": ["Word"]
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -33,7 +33,7 @@ For most users, start with this minimal configuration:
|
||||
}
|
||||
```
|
||||
|
||||
`ankiConnect.deck` is still accepted for backward-compatible polling scope and legacy known-word fallback behavior. For known-word cache scope, prefer `ankiConnect.knownWords.decks` with deck-to-fields mapping.
|
||||
Use the known-word deck map to choose which Anki decks and note fields feed the known-word cache.
|
||||
|
||||
Then customize as needed using the sections below.
|
||||
|
||||
@@ -54,7 +54,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
|
||||
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
|
||||
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||
|
||||
@@ -94,35 +94,10 @@ On macOS, these validation warnings also open a native dialog with full details
|
||||
|
||||
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
|
||||
|
||||
Hot-reloadable fields:
|
||||
|
||||
- `subtitleStyle`
|
||||
- `subtitleSidebar`
|
||||
- `keybindings`
|
||||
- `shortcuts`
|
||||
- `secondarySub.defaultMode`
|
||||
- `stats.toggleKey`
|
||||
- `stats.markWatchedKey`
|
||||
- `logging.level`
|
||||
- `youtube.primarySubLanguages`
|
||||
- `jimaku.*`
|
||||
- `subsync.*`
|
||||
- `ankiConnect.ai.enabled`
|
||||
- `ankiConnect.behavior.autoUpdateNewCards`
|
||||
- `ankiConnect.knownWords.highlightEnabled`
|
||||
- `ankiConnect.knownWords.refreshMinutes`
|
||||
- `ankiConnect.knownWords.addMinedWordsImmediately`
|
||||
- `ankiConnect.knownWords.matchMode`
|
||||
- `ankiConnect.knownWords.decks`
|
||||
- `ankiConnect.nPlusOne.enabled`
|
||||
- `ankiConnect.nPlusOne.minSentenceWords`
|
||||
- `ankiConnect.fields.word`
|
||||
- `ankiConnect.fields.audio`
|
||||
- `ankiConnect.fields.image`
|
||||
- `ankiConnect.fields.sentence`
|
||||
- `ankiConnect.fields.miscInfo`
|
||||
- `ankiConnect.isLapis.sentenceCardModel`
|
||||
- `ankiConnect.isKiku.fieldGrouping`
|
||||
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
|
||||
logging level, selected source-language preferences, Jimaku/Subsync settings, and
|
||||
the Anki known-word, N+1, field, sentence-card, and Kiku options listed in the
|
||||
reference tables below.
|
||||
|
||||
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
|
||||
|
||||
@@ -175,7 +150,7 @@ The configuration file includes several main sections:
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||
@@ -229,7 +204,7 @@ Configure automatic update checks and update notifications:
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
@@ -297,10 +272,10 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------- | ------------------------- | --------------------------------------------------- |
|
||||
| `enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
|
||||
| `port` | number | WebSocket server port (default: 6677) |
|
||||
| Option | Values | Description |
|
||||
| ------------------- | ------------------------- | --------------------------------------------------- |
|
||||
| `websocket.enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
|
||||
| `websocket.port` | number | WebSocket server port (default: 6677) |
|
||||
|
||||
### Annotation WebSocket
|
||||
|
||||
@@ -317,10 +292,10 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------- | --------------- | -------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
|
||||
| `port` | number | Annotation websocket port (default: 6678) |
|
||||
| Option | Values | Description |
|
||||
| ----------------------------- | --------------- | -------------------------------------------------------------- |
|
||||
| `annotationWebsocket.enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
|
||||
| `annotationWebsocket.port` | number | Annotation websocket port (default: 6678) |
|
||||
|
||||
### Texthooker
|
||||
|
||||
@@ -353,10 +328,10 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
```json
|
||||
{
|
||||
"subtitleStyle": {
|
||||
"fontColor": "#cad3f5",
|
||||
"backgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"color": "#cad3f5",
|
||||
"background-color": "transparent",
|
||||
"font-size": "35px",
|
||||
"font-weight": "600",
|
||||
"line-height": "1.35",
|
||||
@@ -366,13 +341,15 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"text-rendering": "geometricPrecision",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||
"font-style": "normal",
|
||||
"backdrop-filter": "blur(6px)"
|
||||
"backdrop-filter": "blur(6px)",
|
||||
"--subtitle-hover-token-color": "#f4dbd6",
|
||||
"--subtitle-hover-token-background-color": "transparent"
|
||||
},
|
||||
"secondary": {
|
||||
"fontColor": "#cad3f5",
|
||||
"backgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"color": "#cad3f5",
|
||||
"background-color": "transparent",
|
||||
"font-size": "24px",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||
}
|
||||
@@ -381,49 +358,49 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||
| `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). |
|
||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
|
||||
| `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`) |
|
||||
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||
| 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 |
|
||||
|
||||
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
||||
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
|
||||
`textShadow` remain supported for hand-written or older configs.
|
||||
the primary subtitle, secondary subtitle, and sidebar CSS objects. The generated example
|
||||
uses that same CSS declaration shape.
|
||||
|
||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||
|
||||
Lookup behavior:
|
||||
|
||||
- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source.
|
||||
- Point the source path at a directory containing `term_meta_bank_*.json` for a fully custom source.
|
||||
- If `sourcePath` is missing or empty, SubMiner searches default install/runtime locations for `frequency-dictionary` directories (for example app resources, user data paths, and current working directory).
|
||||
- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting.
|
||||
- `frequencyDictionary.matchMode` controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
|
||||
- Match mode controls which token text is used for frequency lookups: `headword` (dictionary form) or `surface` (visible subtitle text).
|
||||
- Frequency highlighting skips tokens that look like non-lexical SFX/interjection noise (for example kana reduplication or short kana endings like `っ`), even when dictionary ranks exist.
|
||||
|
||||
In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window.
|
||||
@@ -435,7 +412,7 @@ Character-name highlighting is separate from N+1 and frequency highlighting:
|
||||
- `nameMatchColor` sets the highlight color for those matched character names.
|
||||
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when name matching is enabled.
|
||||
|
||||
Secondary subtitle defaults: `fontFamily: "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults.
|
||||
Secondary subtitle styling lives in the secondary subtitle CSS object. Any CSS property not set there falls back to the secondary subtitle defaults, then the normal renderer defaults.
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of subtitle style configuration options.
|
||||
|
||||
@@ -452,30 +429,36 @@ Configure the parsed-subtitle sidebar modal.
|
||||
"toggleKey": "Backslash",
|
||||
"pauseVideoOnHover": true,
|
||||
"autoScroll": true,
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"fontSize": 16
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"font-size": "16px",
|
||||
"color": "#cad3f5",
|
||||
"background-color": "rgba(73, 77, 100, 0.9)",
|
||||
"--subtitle-sidebar-max-width": "420px"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||
| `backgroundColor` | string | Sidebar shell background color |
|
||||
| `textColor` | hex color | Default cue text color |
|
||||
| `fontFamily` | string | CSS `font-family` value applied to sidebar cue text |
|
||||
| `fontSize` | number | Base sidebar cue font size in CSS pixels (default: `16`) |
|
||||
| `timestampColor` | hex color | Cue timestamp color |
|
||||
| `activeLineColor` | hex color | Active cue text color |
|
||||
| `activeLineBackgroundColor` | string | Active cue background color |
|
||||
| `hoverLineBackgroundColor` | string | Hovered cue background color |
|
||||
| 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. |
|
||||
|
||||
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 |
|
||||
|
||||
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.
|
||||
|
||||
@@ -539,7 +522,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
|
||||
**Display modes:**
|
||||
|
||||
@@ -640,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 `behavior.autoUpdateNewCards` is `false`) |
|
||||
| `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.
|
||||
|
||||
@@ -684,7 +667,7 @@ Important behavior:
|
||||
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
|
||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
||||
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||
- The button-index map is a semantic reference mapping. Changing it does not rewrite the raw numeric descriptor values already stored under controller bindings.
|
||||
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
|
||||
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
|
||||
|
||||
@@ -773,11 +756,11 @@ If you bind a discrete action to an axis manually, include `direction`:
|
||||
}
|
||||
```
|
||||
|
||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||
Treat the button-index map as reference-only unless you are copying values from the debug modal. Updating it alone does not rewrite the hardcoded raw numeric values already present in controller bindings or controller profiles. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||
|
||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||
|
||||
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
|
||||
If one controller reports non-standard raw button numbers, override that controller profile's button-index map using values from the `Alt+Shift+C` debug modal. Use the global button-index map only when the mapping should apply to every controller without a profile.
|
||||
|
||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||
|
||||
@@ -785,21 +768,21 @@ Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing,
|
||||
|
||||
### Manual Card Update Shortcuts
|
||||
|
||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||
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 `behavior.autoUpdateNewCards` is `false`) |
|
||||
| `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:**
|
||||
|
||||
@@ -838,16 +821,11 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
||||
|
||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||
|
||||
Current runtime options:
|
||||
Current runtime options cover automatic card updates, known-word highlighting,
|
||||
JLPT underlines, frequency highlighting, known-word match mode, and Kiku field
|
||||
grouping mode.
|
||||
|
||||
- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`)
|
||||
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
||||
- `ankiConnect.knownWords.matchMode` (`headword` / `surface`)
|
||||
- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`)
|
||||
|
||||
Annotation toggles (`nPlusOne`, `enableJlpt`, `frequencyDictionary.enabled`) only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
|
||||
Default shortcut: `Ctrl+Shift+O`
|
||||
|
||||
@@ -879,7 +857,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `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`) |
|
||||
@@ -889,7 +867,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
||||
|
||||
SubMiner uses the shared provider for:
|
||||
|
||||
- Anki translation/enrichment when `ankiConnect.ai.enabled` is `true`
|
||||
- Anki translation/enrichment when Anki AI is enabled
|
||||
|
||||
### AnkiConnect
|
||||
|
||||
@@ -963,58 +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 |
|
||||
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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 | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `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 per `behavior.mediaInsertMode`; 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 per `behavior.mediaInsertMode` (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", "Word Reading"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.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`) |
|
||||
| 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`) |
|
||||
|
||||
`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.
|
||||
@@ -1044,22 +1021,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
|
||||
### N+1 Word Highlighting
|
||||
|
||||
When `ankiConnect.knownWords.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
|
||||
When known-word highlighting is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering.
|
||||
|
||||
Known-word cache policy:
|
||||
|
||||
- Initial sync runs when the integration starts if the cache is missing or stale.
|
||||
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
||||
- The refresh interval controls the minimum time between syncs; between refreshes, cached words are reused without querying Anki.
|
||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
||||
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
||||
- The known-word deck map accepts an object keyed by deck name.
|
||||
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.knownWords.matchMode` to `"surface"` for raw subtitle text matching.
|
||||
- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set known-word matching to `"surface"` for raw subtitle text matching.
|
||||
- A known-word cache match always receives known-word highlighting, even when part-of-speech filters suppress N+1, frequency, or JLPT annotations for that token.
|
||||
- Legacy moved keys under `ankiConnect.nPlusOne` (`highlightEnabled`, `refreshMinutes`, `matchMode`, `decks`, `knownWord`) and older `ankiConnect.behavior.nPlusOne*` keys are deprecated and only kept for backward compatibility.
|
||||
- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid.
|
||||
- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown.
|
||||
- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts.
|
||||
|
||||
@@ -1158,9 +1134,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
||||
"accessToken": "",
|
||||
"characterDictionary": {
|
||||
"enabled": false,
|
||||
"refreshTtlHours": 168,
|
||||
"maxLoaded": 3,
|
||||
"evictionPolicy": "delete",
|
||||
"profileScope": "all",
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
@@ -1172,17 +1146,15 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||
| `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based |
|
||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
|
||||
| `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.
|
||||
|
||||
@@ -1205,7 +1177,7 @@ Current post-watch behavior:
|
||||
Setup flow details:
|
||||
|
||||
1. Set `anilist.enabled` to `true`.
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||
2. Leave the AniList access-token field empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||
3. Approve access in AniList.
|
||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
||||
- Encryption backend: Linux defaults to `gnome-libsecret`.
|
||||
@@ -1213,7 +1185,7 @@ Setup flow details:
|
||||
|
||||
Token + detection notes:
|
||||
|
||||
- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
|
||||
- The AniList access token can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup.
|
||||
- Detection quality is best when `guessit` is installed and available on `PATH`.
|
||||
- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing.
|
||||
|
||||
@@ -1249,7 +1221,7 @@ External-profile mode behavior:
|
||||
- SubMiner does not open its own Yomitan settings window in this mode.
|
||||
- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
|
||||
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
|
||||
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
|
||||
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without an external Yomitan profile, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
|
||||
|
||||
### Jellyfin
|
||||
|
||||
@@ -1273,23 +1245,23 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `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.enabled` and `remoteControlEnabled`) |
|
||||
| `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`) |
|
||||
| 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`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken`, `jellyfin.userId`, `jellyfin.clientName`, `jellyfin.deviceId`, `jellyfin.clientVersion`, and `jellyfin.remoteControlDeviceName` config keys are not resolver-backed settings in the current runtime. 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.
|
||||
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.
|
||||
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||
|
||||
@@ -1304,7 +1276,7 @@ Launcher subcommands:
|
||||
|
||||
See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide.
|
||||
|
||||
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
|
||||
Jellyfin remote auto-connect runs only when Jellyfin integration, remote control, and remote auto-connect are all enabled.
|
||||
|
||||
Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin.
|
||||
|
||||
@@ -1325,12 +1297,12 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `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 |
|
||||
| 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 |
|
||||
|
||||
Setup steps:
|
||||
|
||||
@@ -1392,7 +1364,7 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
|
||||
| `immersionTracking.enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
|
||||
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
|
||||
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
|
||||
| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. |
|
||||
@@ -1407,6 +1379,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
||||
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
|
||||
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
||||
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
||||
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
|
||||
|
||||
You can also disable immersion tracking for a single session using:
|
||||
|
||||
@@ -1436,6 +1411,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
||||
{
|
||||
"stats": {
|
||||
"toggleKey": "Backquote",
|
||||
"markWatchedKey": "KeyW",
|
||||
"serverPort": 6969,
|
||||
"autoStartServer": true,
|
||||
"autoOpenBrowser": false
|
||||
@@ -1445,7 +1421,8 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||
| `stats.toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||
| `markWatchedKey` | Electron key code | Key code to mark the current video as watched and advance to the next playlist entry. Default `KeyW`. |
|
||||
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
|
||||
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
||||
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
|
||||
@@ -1465,17 +1442,31 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
{
|
||||
"mpv": {
|
||||
"executablePath": "",
|
||||
"launchMode": "normal",
|
||||
"profile": "",
|
||||
"launchMode": "normal"
|
||||
"socketPath": "\\\\.\\pipe\\subminer-socket",
|
||||
"backend": "auto",
|
||||
"autoStartSubMiner": true,
|
||||
"pauseUntilOverlayReady": true,
|
||||
"subminerBinaryPath": "",
|
||||
"aniskipEnabled": true,
|
||||
"aniskipButtonKey": "TAB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
@@ -1507,14 +1498,14 @@ Current launcher behavior:
|
||||
- If YouTube/mpv already exposes an authoritative matching subtitle track, SubMiner reuses it; otherwise it downloads and injects only the missing side.
|
||||
- SubMiner loads the primary subtitle plus a best-effort secondary subtitle.
|
||||
- Playback waits only for primary subtitle readiness; secondary failures do not block playback.
|
||||
- English secondary subtitles are selected from `secondarySub.secondarySubLanguages` when primary language matches are unavailable.
|
||||
- English secondary subtitles are selected from the secondary-subtitle language list when primary language matches are unavailable.
|
||||
- Native mpv secondary subtitle rendering stays hidden during this flow so the SubMiner overlay remains the visible secondary subtitle surface.
|
||||
- If primary subtitle loading fails, use `Ctrl+Alt+C` to open the subtitle modal and pick a track.
|
||||
|
||||
Language targets are derived from subtitle config:
|
||||
|
||||
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
||||
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty)
|
||||
- secondary track: secondary-subtitle language list (falls back to English when empty)
|
||||
- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick.
|
||||
- Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed.
|
||||
|
||||
|
||||
@@ -36,6 +36,37 @@ flowchart TB
|
||||
style E fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
```
|
||||
|
||||
## Runtime Sockets
|
||||
|
||||
The renderer↔main bridge above lives *inside* the Electron app. A separate set of OS sockets connects the app to the other runtimes — mpv and the launcher/plugin. These carry no renderer payloads and bypass the contract/validator layer; they are command and property channels between processes.
|
||||
|
||||
- **mpv IPC socket** (`/tmp/subminer-socket`, or `\\.\pipe\subminer-socket` on Windows): the `MpvIpcClient` in the main process connects here to send JSON commands and subscribe to playback/subtitle properties via `observe_property`. Created by mpv's `--input-ipc-server`.
|
||||
- **App control socket** (`/tmp/subminer-control-<uid>-<hash>.sock`, or a named pipe on Windows): the launcher and the mpv plugin send CLI-style commands (`--start`, `--show-visible-overlay`, `--texthooker`) to a running app here. It also dedupes a second `subminer` invocation into the existing instance instead of launching twice.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef app fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
|
||||
subgraph MpvProc["mpv process"]
|
||||
direction TB
|
||||
Mpv["mpv core"]:::ext
|
||||
Plugin["SubMiner plugin (Lua)"]:::extrt
|
||||
end
|
||||
|
||||
Launcher["Launcher CLI"]:::extrt
|
||||
App["SubMiner app (Electron main)"]:::app
|
||||
|
||||
App <-->|"mpv IPC socket · /tmp/subminer-socket<br/>JSON commands + property observe"| Mpv
|
||||
Launcher -->|"app control socket · /tmp/subminer-control-*<br/>--start, --show-visible-overlay, …"| App
|
||||
Plugin -->|"app control socket<br/>spawn / attach"| App
|
||||
|
||||
style MpvProc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
```
|
||||
|
||||
How these sockets are established during launch is covered in [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
| File | Role |
|
||||
|
||||
@@ -163,6 +163,8 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
|
||||
## Lifecycle
|
||||
|
||||
For how the plugin's auto-start fits into the full launch sequence — including when the launcher starts the overlay instead of the plugin — see [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
}, // Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.
|
||||
"bindings": {
|
||||
"toggleLookup": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
@@ -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.
|
||||
@@ -531,7 +532,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -594,9 +595,7 @@
|
||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
||||
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||
"characterDictionary": {
|
||||
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
|
||||
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
|
||||
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete
|
||||
"profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active
|
||||
"collapsibleSections": {
|
||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
||||
|
||||
@@ -12,7 +12,7 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
||||
1. SubMiner queries your configured Anki decks for expression/word fields such as `Expression` or `Word`.
|
||||
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
||||
3. When a subtitle line appears, each token is checked against the cache.
|
||||
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
|
||||
@@ -24,13 +24,15 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
||||
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. |
|
||||
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||
|
||||
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
|
||||
|
||||
::: tip
|
||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||
:::
|
||||
|
||||
@@ -14,6 +14,8 @@ SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the so
|
||||
|
||||
SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart.
|
||||
|
||||
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
||||
|
||||
## Logging and App Mode
|
||||
|
||||
- Default log output is `info`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
@@ -275,6 +276,31 @@ async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
|
||||
throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForSocketReady(socketPath: string, timeoutMs = 1500): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!fs.existsSync(socketPath)) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
continue;
|
||||
}
|
||||
const ready = await new Promise<boolean>((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
socket.once('connect', () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once('error', () => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
socket.connect(socketPath);
|
||||
});
|
||||
if (ready) return true;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startFakeControlServer(
|
||||
smokeCase: SmokeCase,
|
||||
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
|
||||
@@ -379,7 +405,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 120));
|
||||
await waitForSocketReady(smokeCase.socketPath);
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
['mpv', 'status', '--log-level', 'debug'],
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.15.0-beta.9",
|
||||
"version": "0.15.0-beta.10",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging. The new `logging.rotation` config sets daily log retention (default 7 days), and `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled for debugging.
|
||||
|
||||
- **Yomitan Popup Visibility:** The new `subtitleStyle.primaryVisibleOnYomitanPopup` option keeps hover-mode primary subtitles visible while a Yomitan lookup popup is open.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically.
|
||||
@@ -63,13 +65,13 @@
|
||||
|
||||
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
||||
|
||||
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
|
||||
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status. Repeated missing-token checks no longer rapidly exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
|
||||
|
||||
- **Anki:** Sentence-audio padding is now opt-in by default. When padding is configured, animated AVIF freeze-frame duration is correctly aligned to the word audio length without double-counting sentence padding. Multi-line sentence mining stays aligned when repeated subtitle text appears in the selected history range. Manual clipboard card updates from YouTube playback now use mpv's resolved stream URLs for generated audio and images.
|
||||
- **Anki:** Sentence-audio padding is now opt-in by default. When padding is configured, animated AVIF freeze-frame duration is correctly aligned to the word audio length without double-counting sentence audio padding. Multi-line sentence mining stays aligned when repeated subtitle text appears in the selected history range. Manual clipboard card updates from YouTube playback now use mpv's resolved stream URLs for generated audio and images. Sentence cards now refresh the current secondary subtitle before saving so the translation field contains the loaded subtitle rather than repeating the primary text. Kiku duplicate-card detection correctly groups fields, modal-open acknowledgement races no longer cancel the merge flow, and merged fields follow Kiku's group ordering, sentence-audio, furigana, and tag semantics.
|
||||
|
||||
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
|
||||
|
||||
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
|
||||
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results. Character dictionary manager keyboard shortcuts are now correctly forwarded to the mpv plugin.
|
||||
|
||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
|
||||
|
||||
@@ -81,7 +83,7 @@
|
||||
|
||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||
|
||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
|
||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Frequency annotations are also corrected for Yomitan single-token compounds with internal particles such as `目の前`, while pure grammar and kana helper spans remain unannotated.
|
||||
|
||||
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
||||
|
||||
@@ -89,7 +91,7 @@
|
||||
|
||||
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata instead of stream URLs, so watched episodes merge with matching local library titles and display clean names.
|
||||
|
||||
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled.
|
||||
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled. Yomitan-enriched cards mined from the sidebar now use audio and images from the clicked subtitle line rather than the current primary line.
|
||||
|
||||
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
|
||||
|
||||
@@ -105,6 +107,10 @@
|
||||
|
||||
- **Versioned Docs:** Stable docs are now published at the site root with current development docs under `/main/`. Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests paths incorrectly, local dev version routes serve warmed archive files instead of redirecting to production, and internal README files no longer break archived builds.
|
||||
|
||||
- **Configuration Reference:** All previously undocumented config options are now covered, including `subtitleStyle.primaryDefaultMode`, `stats.markWatchedKey`, `immersionTracking.lifetimeSummaries.*`, and all seven `mpv.*` launcher options. Updated known-word cache docs and examples to recommend expression/word fields.
|
||||
|
||||
- **Architecture Docs:** Added a Playback Startup Flow diagram showing how managed launches inject the plugin, establish the IPC socket, and bring up the overlay via the two convergent triggers. Added a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
@@ -288,6 +288,48 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
|
||||
assert.equal(privateState.runtime.proxyServer, null);
|
||||
});
|
||||
|
||||
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
isKiku: {
|
||||
enabled: true,
|
||||
fieldGrouping: 'manual',
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
let groupingTriggered = 0;
|
||||
const internals = integration as unknown as {
|
||||
cardCreationService: {
|
||||
createSentenceCard: (
|
||||
sentence: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
secondarySubText?: string,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
fieldGroupingService: {
|
||||
triggerFieldGroupingForLastAddedCard: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
internals.cardCreationService = {
|
||||
createSentenceCard: async () => {
|
||||
integration.trackDuplicateNoteIdsForNote(42, [7]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
internals.fieldGroupingService = {
|
||||
triggerFieldGroupingForLastAddedCard: async () => {
|
||||
groupingTriggered += 1;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
|
||||
assert.equal(groupingTriggered, 1);
|
||||
});
|
||||
|
||||
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
@@ -316,7 +358,7 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
|
||||
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
@@ -340,9 +382,9 @@ test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged Se
|
||||
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="101">[sound:keep.mp3]</span><span data-group-id="202">[sound:new.mp3]</span>',
|
||||
'<span data-group-id="202">[sound:new.mp3]</span><span data-group-id="101">[sound:keep.mp3]</span>',
|
||||
);
|
||||
assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator uses generated media fallback when source lacks audio', async () => {
|
||||
@@ -374,7 +416,7 @@ test('FieldGroupingMergeCollaborator uses generated media fallback when source l
|
||||
assert.equal(merged.SentenceAudio, '<span data-group-id="22">[sound:generated.mp3]</span>');
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and image values when merging into a new duplicate card', async () => {
|
||||
test('FieldGroupingMergeCollaborator keeps independent groups for identical sentence, audio, and image values', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
@@ -400,10 +442,19 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.Sentence, '<span data-group-id="202">same sentence</span>');
|
||||
assert.equal(merged.SentenceAudio, '<span data-group-id="202">[sound:same.mp3]</span>');
|
||||
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
|
||||
assert.equal(merged.ExpressionAudio, merged.SentenceAudio);
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="202">same sentence</span><span data-group-id="101">same sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="202">[sound:same.mp3]</span><span data-group-id="101">[sound:same.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="202" src="same.png"><img data-group-id="101" src="same.png">',
|
||||
);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
|
||||
test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => {
|
||||
|
||||
+102
-27
@@ -29,7 +29,7 @@ import {
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import { MpvClient } from './types/runtime';
|
||||
import { NPlusOneMatchMode } from './types/subtitle';
|
||||
import type { NPlusOneMatchMode, SubtitleMiningContext } from './types/subtitle';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
|
||||
import {
|
||||
getConfiguredWordFieldCandidates,
|
||||
@@ -149,6 +149,7 @@ export class AnkiIntegration {
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
|
||||
@@ -453,11 +454,13 @@ export class AnkiIntegration {
|
||||
mergeFieldValue: (existing, newValue, overwrite) =>
|
||||
this.mergeFieldValue(existing, newValue, overwrite),
|
||||
generateAudioFilename: () => this.generateAudioFilename(),
|
||||
generateAudio: () => this.generateAudio(),
|
||||
generateAudio: (context) => this.generateAudio(context),
|
||||
generateImageFilename: () => this.generateImageFilename(),
|
||||
generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds),
|
||||
generateImage: (animatedLeadInSeconds, context) =>
|
||||
this.generateImage(animatedLeadInSeconds, context),
|
||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
@@ -474,6 +477,7 @@ export class AnkiIntegration {
|
||||
client: {
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||
addTags: (noteIds, tags) => this.client.addTags(noteIds, tags),
|
||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
@@ -673,7 +677,55 @@ export class AnkiIntegration {
|
||||
return `${prefix}<b>${highlightedText}</b>${suffix}`;
|
||||
}
|
||||
|
||||
private async generateAudio(): Promise<Buffer | null> {
|
||||
private consumeSubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
if (!this.consumeSubtitleMiningContextCallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.consumeSubtitleMiningContextCallback();
|
||||
} catch (error) {
|
||||
log.warn('Subtitle mining context callback failed:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getSubtitleMediaRange(context?: SubtitleMiningContext): {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
} {
|
||||
if (
|
||||
context &&
|
||||
Number.isFinite(context.startTime) &&
|
||||
Number.isFinite(context.endTime) &&
|
||||
context.endTime > context.startTime
|
||||
) {
|
||||
return {
|
||||
startTime: context.startTime,
|
||||
endTime: context.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
Number.isFinite(this.mpvClient.currentSubStart) &&
|
||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||
) {
|
||||
return {
|
||||
startTime: this.mpvClient.currentSubStart,
|
||||
endTime: this.mpvClient.currentSubEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||
const fallback = this.getFallbackDurationSeconds() / 2;
|
||||
return {
|
||||
startTime: currentTime - fallback,
|
||||
endTime: currentTime + fallback,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateAudio(context?: SubtitleMiningContext): Promise<Buffer | null> {
|
||||
const mpvClient = this.mpvClient;
|
||||
if (!mpvClient || !mpvClient.currentVideoPath) {
|
||||
return null;
|
||||
@@ -683,15 +735,7 @@ export class AnkiIntegration {
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
let startTime = mpvClient.currentSubStart;
|
||||
let endTime = mpvClient.currentSubEnd;
|
||||
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
const currentTime = mpvClient.currentTimePos || 0;
|
||||
const fallback = this.getFallbackDurationSeconds() / 2;
|
||||
startTime = currentTime - fallback;
|
||||
endTime = currentTime + fallback;
|
||||
}
|
||||
const { startTime, endTime } = this.getSubtitleMediaRange(context);
|
||||
|
||||
return this.mediaGenerator.generateAudio(
|
||||
videoPath,
|
||||
@@ -702,7 +746,10 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
private async generateImage(animatedLeadInSeconds = 0): Promise<Buffer | null> {
|
||||
private async generateImage(
|
||||
animatedLeadInSeconds = 0,
|
||||
context?: SubtitleMiningContext,
|
||||
): Promise<Buffer | null> {
|
||||
if (!this.mpvClient || !this.mpvClient.currentVideoPath) {
|
||||
return null;
|
||||
}
|
||||
@@ -711,22 +758,16 @@ export class AnkiIntegration {
|
||||
if (!videoPath) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const mediaRange = this.getSubtitleMediaRange(context);
|
||||
const timestamp = context
|
||||
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
|
||||
: this.mpvClient.currentTimePos || 0;
|
||||
|
||||
if (this.config.media?.imageType === 'avif') {
|
||||
let startTime = this.mpvClient.currentSubStart;
|
||||
let endTime = this.mpvClient.currentSubEnd;
|
||||
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
const fallback = this.getFallbackDurationSeconds() / 2;
|
||||
startTime = timestamp - fallback;
|
||||
endTime = timestamp + fallback;
|
||||
}
|
||||
|
||||
return this.mediaGenerator.generateAnimatedImage(
|
||||
videoPath,
|
||||
startTime,
|
||||
endTime,
|
||||
mediaRange.startTime,
|
||||
mediaRange.endTime,
|
||||
this.config.media?.audioPadding,
|
||||
{
|
||||
fps: this.config.media?.animatedFps,
|
||||
@@ -1064,18 +1105,48 @@ export class AnkiIntegration {
|
||||
endTime: number,
|
||||
secondarySubText?: string,
|
||||
): Promise<boolean> {
|
||||
return this.cardCreationService.createSentenceCard(
|
||||
const trackedDuplicateNoteIdsBeforeCreate = new Set(this.trackedDuplicateNoteIds.keys());
|
||||
const created = await this.cardCreationService.createSentenceCard(
|
||||
sentence,
|
||||
startTime,
|
||||
endTime,
|
||||
secondarySubText,
|
||||
);
|
||||
if (
|
||||
created &&
|
||||
this.shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
|
||||
trackedDuplicateNoteIdsBeforeCreate,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await this.fieldGroupingService.triggerFieldGroupingForLastAddedCard();
|
||||
} catch (error) {
|
||||
log.warn('Failed to trigger field grouping after sentence card creation:', error);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
}
|
||||
|
||||
private shouldTriggerFieldGroupingAfterLocalSentenceCardCreate(
|
||||
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
|
||||
): boolean {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const noteId of this.trackedDuplicateNoteIds.keys()) {
|
||||
if (!trackedDuplicateNoteIdsBeforeCreate.has(noteId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async findDuplicateNote(
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
@@ -1287,6 +1358,10 @@ export class AnkiIntegration {
|
||||
this.knownWordCacheUpdatedCallback = callback;
|
||||
}
|
||||
|
||||
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
|
||||
this.consumeSubtitleMiningContextCallback = callback;
|
||||
}
|
||||
|
||||
resolveCurrentNoteId(noteId: number): number {
|
||||
let resolved = noteId;
|
||||
const seen = new Set<number>();
|
||||
|
||||
@@ -74,13 +74,13 @@ function makeNote(noteId: number, fields: Record<string, string>): FieldGrouping
|
||||
};
|
||||
}
|
||||
|
||||
test('getGroupableFieldNames includes configured fields without duplicating ExpressionAudio', () => {
|
||||
test('getGroupableFieldNames includes Kiku context fields and omits word audio fields', () => {
|
||||
const { collaborator } = createCollaborator({
|
||||
config: {
|
||||
fields: {
|
||||
image: 'Illustration',
|
||||
sentence: 'SentenceText',
|
||||
audio: 'ExpressionAudio',
|
||||
audio: 'CustomWordAudio',
|
||||
miscInfo: 'ExtraInfo',
|
||||
},
|
||||
},
|
||||
@@ -97,33 +97,84 @@ test('getGroupableFieldNames includes configured fields without duplicating Expr
|
||||
]);
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields syncs a custom audio field from merged SentenceAudio', async () => {
|
||||
const { collaborator } = createCollaborator({
|
||||
config: {
|
||||
fields: {
|
||||
audio: 'CustomAudio',
|
||||
},
|
||||
},
|
||||
});
|
||||
test('computeFieldGroupingMergedFields groups both notes and sorts by descending group id when keeping original', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
1,
|
||||
2,
|
||||
makeNote(1, {
|
||||
SentenceAudio: '[sound:keep.mp3]',
|
||||
CustomAudio: '[sound:stale.mp3]',
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
Sentence: 'original sentence',
|
||||
SentenceAudio: '[sound:original-a.mp3] [sound:original-b.mp3]',
|
||||
Picture: '<img src="original.png">',
|
||||
MiscInfo: 'original misc',
|
||||
ExpressionAudio: '[sound:word.mp3]',
|
||||
}),
|
||||
makeNote(2, {
|
||||
makeNote(200, {
|
||||
Sentence: 'new sentence',
|
||||
SentenceAudio: '[sound:new.mp3]',
|
||||
Picture: '<img src="new.png">',
|
||||
MiscInfo: 'new misc',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="1">[sound:keep.mp3]</span><span data-group-id="2">[sound:new.mp3]</span>',
|
||||
merged.Sentence,
|
||||
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="300">[sound:original-a.mp3] [sound:original-b.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
||||
);
|
||||
assert.equal(
|
||||
merged.MiscInfo,
|
||||
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
||||
);
|
||||
assert.equal('ExpressionAudio' in merged, false);
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields sorts original before new when merging original into a newer target', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
200,
|
||||
300,
|
||||
makeNote(200, {
|
||||
Sentence: 'new sentence',
|
||||
SentenceAudio: '[sound:new.mp3]',
|
||||
Picture: '<img src="new.png">',
|
||||
MiscInfo: 'new misc',
|
||||
}),
|
||||
makeNote(300, {
|
||||
Sentence: 'original sentence',
|
||||
SentenceAudio: '[sound:original.mp3]',
|
||||
Picture: '<img src="original.png">',
|
||||
MiscInfo: 'original misc',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="300">original sentence</span><span data-group-id="200">new sentence</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.SentenceAudio,
|
||||
'<span data-group-id="300">[sound:original.mp3]</span><span data-group-id="200">[sound:new.mp3]</span>',
|
||||
);
|
||||
assert.equal(
|
||||
merged.Picture,
|
||||
'<img data-group-id="300" src="original.png"><img data-group-id="200" src="new.png">',
|
||||
);
|
||||
assert.equal(
|
||||
merged.MiscInfo,
|
||||
'<span data-group-id="300">original misc</span><span data-group-id="200">new misc</span>',
|
||||
);
|
||||
assert.equal(merged.CustomAudio, merged.SentenceAudio);
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields keeps strict fields when source is empty and warns on malformed spans', async () => {
|
||||
@@ -147,7 +198,7 @@ test('computeFieldGroupingMergedFields keeps strict fields when source is empty
|
||||
|
||||
assert.equal(
|
||||
merged.Sentence,
|
||||
'<span data-group-id="3"><span data-group-id="abc">keep sentence</span></span><span data-group-id="4">source sentence</span>',
|
||||
'<span data-group-id="4">source sentence</span><span data-group-id="3"><span data-group-id="abc">keep sentence</span></span>',
|
||||
);
|
||||
assert.equal(merged.SentenceAudio, '<span data-group-id="4">[sound:source.mp3]</span>');
|
||||
assert.equal(warnings.length, 4);
|
||||
@@ -199,3 +250,21 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
|
||||
assert.equal(withMedia.Picture, '<img data-group-id="11" src="generated.png">');
|
||||
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
|
||||
});
|
||||
|
||||
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
|
||||
const { collaborator } = createCollaborator();
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
SentenceFurigana: 'original furigana',
|
||||
}),
|
||||
makeNote(200, {
|
||||
SentenceFurigana: '',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.SentenceFurigana, '');
|
||||
});
|
||||
|
||||
@@ -51,9 +51,6 @@ export class FieldGroupingMergeCollaborator {
|
||||
fields.push('Picture');
|
||||
if (config.fields?.image) fields.push(config.fields?.image);
|
||||
if (config.fields?.sentence) fields.push(config.fields?.sentence);
|
||||
if (config.fields?.audio && config.fields?.audio.toLowerCase() !== 'expressionaudio') {
|
||||
fields.push(config.fields?.audio);
|
||||
}
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const sentenceAudioField = sentenceCardConfig.audioField;
|
||||
if (!fields.includes(sentenceAudioField)) fields.push(sentenceAudioField);
|
||||
@@ -94,12 +91,6 @@ export class FieldGroupingMergeCollaborator {
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceFields['SentenceFurigana'] && sourceFields['Sentence']) {
|
||||
sourceFields['SentenceFurigana'] = sourceFields['Sentence'];
|
||||
}
|
||||
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
|
||||
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
|
||||
}
|
||||
if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
|
||||
sourceFields[configuredWordField] = sourceFields['Expression'];
|
||||
}
|
||||
@@ -112,13 +103,6 @@ export class FieldGroupingMergeCollaborator {
|
||||
if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
|
||||
sourceFields['Word'] = sourceFields[configuredWordField];
|
||||
}
|
||||
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
|
||||
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
|
||||
}
|
||||
if (!sourceFields['ExpressionAudio'] && sourceFields['SentenceAudio']) {
|
||||
sourceFields['ExpressionAudio'] = sourceFields['SentenceAudio'];
|
||||
}
|
||||
|
||||
if (
|
||||
config.fields?.sentence &&
|
||||
!sourceFields[config.fields?.sentence] &&
|
||||
@@ -169,6 +153,20 @@ export class FieldGroupingMergeCollaborator {
|
||||
const isStrictField = this.shouldUseStrictSpanGrouping(keepFieldName);
|
||||
if (!existingValue.trim() && !newValue.trim()) continue;
|
||||
|
||||
if (keepFieldNormalized === 'sentencefurigana') {
|
||||
mergedFields[keepFieldName] =
|
||||
existingValue.trim() && newValue.trim()
|
||||
? this.applyFieldGrouping(
|
||||
existingValue,
|
||||
newValue,
|
||||
keepNoteId,
|
||||
deleteNoteId,
|
||||
keepFieldName,
|
||||
)
|
||||
: '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStrictField) {
|
||||
mergedFields[keepFieldName] = this.applyFieldGrouping(
|
||||
existingValue,
|
||||
@@ -191,29 +189,6 @@ export class FieldGroupingMergeCollaborator {
|
||||
}
|
||||
}
|
||||
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
const resolvedSentenceAudioField = this.deps.resolveFieldName(
|
||||
keepFieldNames,
|
||||
sentenceCardConfig.audioField || 'SentenceAudio',
|
||||
);
|
||||
const resolvedExpressionAudioField = this.deps.resolveFieldName(
|
||||
keepFieldNames,
|
||||
config.fields?.audio || 'ExpressionAudio',
|
||||
);
|
||||
if (
|
||||
resolvedSentenceAudioField &&
|
||||
resolvedExpressionAudioField &&
|
||||
resolvedExpressionAudioField !== resolvedSentenceAudioField
|
||||
) {
|
||||
const mergedSentenceAudioValue =
|
||||
mergedFields[resolvedSentenceAudioField] ||
|
||||
keepNoteInfo.fields[resolvedSentenceAudioField]?.value ||
|
||||
'';
|
||||
if (mergedSentenceAudioValue.trim()) {
|
||||
mergedFields[resolvedExpressionAudioField] = mergedSentenceAudioValue;
|
||||
}
|
||||
}
|
||||
|
||||
return mergedFields;
|
||||
}
|
||||
|
||||
@@ -228,22 +203,14 @@ export class FieldGroupingMergeCollaborator {
|
||||
}
|
||||
|
||||
private extractUngroupedValue(value: string): string {
|
||||
const groupedSpanRegex = /<span\s+data-group-id="[^"]*">[\s\S]*?<\/span>/gi;
|
||||
const ungrouped = value.replace(groupedSpanRegex, '').trim();
|
||||
const ungrouped = this.extractUngroupedRemainder(value);
|
||||
if (ungrouped) return ungrouped;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private extractLastSoundTag(value: string): string {
|
||||
const matches = value.match(/\[sound:[^\]]+\]/g);
|
||||
if (!matches || matches.length === 0) return '';
|
||||
return matches[matches.length - 1]!;
|
||||
}
|
||||
|
||||
private extractLastImageTag(value: string): string {
|
||||
const matches = value.match(/<img\b[^>]*>/gi);
|
||||
if (!matches || matches.length === 0) return '';
|
||||
return matches[matches.length - 1]!;
|
||||
private extractUngroupedRemainder(value: string): string {
|
||||
const groupedSpanRegex = /<span\b[^>]*data-group-id="[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
|
||||
return value.replace(groupedSpanRegex, '').trim();
|
||||
}
|
||||
|
||||
private extractImageTags(value: string): string[] {
|
||||
@@ -274,7 +241,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
}
|
||||
}
|
||||
|
||||
const spanRegex = /<span\s+data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
const spanRegex = /<span\b[^>]*data-group-id="(\d+)"[^>]*>([\s\S]*?)<\/span>/gi;
|
||||
let match;
|
||||
while ((match = spanRegex.exec(value)) !== null) {
|
||||
const groupId = Number(match[1]);
|
||||
@@ -298,25 +265,16 @@ export class FieldGroupingMergeCollaborator {
|
||||
fieldName: string,
|
||||
): { groupId: number; content: string }[] {
|
||||
const entries = this.extractSpanEntries(value, fieldName);
|
||||
if (entries.length === 0) {
|
||||
const ungrouped = this.normalizeStrictGroupedValue(
|
||||
this.extractUngroupedValue(value),
|
||||
fieldName,
|
||||
);
|
||||
if (ungrouped) {
|
||||
entries.push({ groupId: fallbackGroupId, content: ungrouped });
|
||||
}
|
||||
const ungroupedSource =
|
||||
entries.length > 0
|
||||
? this.extractUngroupedRemainder(value)
|
||||
: this.extractUngroupedValue(value);
|
||||
const ungrouped = this.normalizeStrictGroupedValue(ungroupedSource, fieldName);
|
||||
if (ungrouped) {
|
||||
entries.push({ groupId: fallbackGroupId, content: ungrouped });
|
||||
}
|
||||
|
||||
const unique: { groupId: number; content: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const key = entry.content;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(entry);
|
||||
}
|
||||
return unique;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private parsePictureEntries(
|
||||
@@ -351,29 +309,13 @@ export class FieldGroupingMergeCollaborator {
|
||||
if (!ungrouped) return '';
|
||||
|
||||
const normalizedField = fieldName.toLowerCase();
|
||||
if (normalizedField === 'sentenceaudio' || normalizedField === 'expressionaudio') {
|
||||
const lastSoundTag = this.extractLastSoundTag(ungrouped);
|
||||
if (!lastSoundTag) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
|
||||
}
|
||||
return lastSoundTag || ungrouped;
|
||||
}
|
||||
|
||||
if (normalizedField === 'picture') {
|
||||
const lastImageTag = this.extractLastImageTag(ungrouped);
|
||||
if (!lastImageTag) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'missing-image-tag');
|
||||
}
|
||||
return lastImageTag || ungrouped;
|
||||
if (normalizedField === 'sentenceaudio' && !/\[sound:[^\]]+\]/.test(ungrouped)) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
|
||||
}
|
||||
|
||||
return ungrouped;
|
||||
}
|
||||
|
||||
private getPictureDedupKey(tag: string): string {
|
||||
return tag.replace(/\sdata-group-id="[^"]*"/gi, '').trim();
|
||||
}
|
||||
|
||||
private getStrictSpanGroupingFields(): Set<string> {
|
||||
const strictFields = new Set(this.strictGroupingFieldDefaults);
|
||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||
@@ -390,6 +332,16 @@ export class FieldGroupingMergeCollaborator {
|
||||
return this.getStrictSpanGroupingFields().has(normalized);
|
||||
}
|
||||
|
||||
private isPictureField(fieldName: string): boolean {
|
||||
const normalized = fieldName.toLowerCase();
|
||||
const configuredImageField = this.deps.getConfig().fields?.image?.toLowerCase();
|
||||
return normalized === 'picture' || normalized === configuredImageField;
|
||||
}
|
||||
|
||||
private sortEntriesByGroupIdDescending<T extends { groupId: number }>(entries: T[]): T[] {
|
||||
return [...entries].sort((a, b) => b.groupId - a.groupId);
|
||||
}
|
||||
|
||||
private applyFieldGrouping(
|
||||
existingValue: string,
|
||||
newValue: string,
|
||||
@@ -398,24 +350,15 @@ export class FieldGroupingMergeCollaborator {
|
||||
fieldName: string,
|
||||
): string {
|
||||
if (this.shouldUseStrictSpanGrouping(fieldName)) {
|
||||
if (fieldName.toLowerCase() === 'picture') {
|
||||
if (this.isPictureField(fieldName)) {
|
||||
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
|
||||
const sourceEntries = this.parsePictureEntries(newValue, sourceGroupId);
|
||||
if (keepEntries.length === 0 && sourceEntries.length === 0) {
|
||||
return existingValue || newValue;
|
||||
}
|
||||
const mergedTags = keepEntries.map((entry) =>
|
||||
this.ensureImageGroupId(entry.tag, entry.groupId),
|
||||
);
|
||||
const seen = new Set(mergedTags.map((tag) => this.getPictureDedupKey(tag)));
|
||||
for (const entry of sourceEntries) {
|
||||
const normalized = this.ensureImageGroupId(entry.tag, entry.groupId);
|
||||
const dedupKey = this.getPictureDedupKey(normalized);
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
mergedTags.push(normalized);
|
||||
}
|
||||
return mergedTags.join('');
|
||||
return this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries])
|
||||
.map((entry) => 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) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
.join('');
|
||||
}
|
||||
const merged = [...keepEntries];
|
||||
const seen = new Set(keepEntries.map((entry) => entry.content));
|
||||
for (const entry of sourceEntries) {
|
||||
const key = entry.content;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(entry);
|
||||
}
|
||||
const merged = this.sortEntriesByGroupIdDescending([...keepEntries, ...sourceEntries]);
|
||||
if (merged.length === 0) return existingValue;
|
||||
return merged
|
||||
.map((entry) => `<span data-group-id="${entry.groupId}">${entry.content}</span>`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types/an
|
||||
type NoteInfo = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
type ManualChoice = {
|
||||
@@ -23,6 +24,7 @@ type FieldGroupingCallback = (data: {
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const deleted: number[][] = [];
|
||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||
const mergeCalls: Array<{
|
||||
@@ -49,6 +51,9 @@ function createWorkflowHarness() {
|
||||
updateNoteFields: async (noteId: number, fields: Record<string, string>) => {
|
||||
updates.push({ noteId, fields });
|
||||
},
|
||||
addTags: async (noteIds: number[], tags: string[]) => {
|
||||
addedTags.push({ noteIds, tags });
|
||||
},
|
||||
deleteNotes: async (noteIds: number[]) => {
|
||||
deleted.push(noteIds);
|
||||
},
|
||||
@@ -117,6 +122,7 @@ function createWorkflowHarness() {
|
||||
workflow: new FieldGroupingWorkflow(deps),
|
||||
updates,
|
||||
deleted,
|
||||
addedTags,
|
||||
rememberedMerges,
|
||||
statuses,
|
||||
mergeCalls,
|
||||
@@ -145,6 +151,31 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
|
||||
assert.equal(harness.statuses.length, 1);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow merges source tags into target and filters special source tags', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async (noteIds: number[]) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: `word-${noteId}` },
|
||||
Sentence: { value: `line-${noteId}` },
|
||||
},
|
||||
tags:
|
||||
noteId === 1 ? ['kinkoi', 'marked'] : ['SubMiner', 'marked', 'leech', 'potential_leech'],
|
||||
}));
|
||||
|
||||
await harness.workflow.handleAuto(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
tags: ['SubMiner', 'marked', 'leech', 'potential_leech'],
|
||||
});
|
||||
|
||||
assert.deepEqual(harness.addedTags, [{ noteIds: [1], tags: ['SubMiner'] }]);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual mode returns false when callback unavailable', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
export interface FieldGroupingWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface FieldGroupingWorkflowDeps {
|
||||
client: {
|
||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||
addTags(noteIds: number[], tags: string[]): Promise<void>;
|
||||
deleteNotes(noteIds: number[]): Promise<void>;
|
||||
};
|
||||
getConfig: () => {
|
||||
@@ -156,6 +158,11 @@ export class FieldGroupingWorkflow {
|
||||
await this.deps.addConfiguredTagsToNote(keepNoteId);
|
||||
}
|
||||
|
||||
const tagsToAdd = this.getMergeTagsToAdd(keepNoteInfo, deleteNoteInfo);
|
||||
if (tagsToAdd.length > 0) {
|
||||
await this.deps.client.addTags([keepNoteId], tagsToAdd);
|
||||
}
|
||||
|
||||
if (deleteDuplicate) {
|
||||
await this.deps.client.deleteNotes([deleteNoteId]);
|
||||
this.deps.removeTrackedNoteId(deleteNoteId);
|
||||
@@ -200,6 +207,24 @@ export class FieldGroupingWorkflow {
|
||||
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
|
||||
}
|
||||
|
||||
private getMergeTagsToAdd(
|
||||
keepNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
deleteNoteInfo: FieldGroupingWorkflowNoteInfo,
|
||||
): string[] {
|
||||
const targetTags = new Set((keepNoteInfo.tags ?? []).map((tag) => tag.trim()).filter(Boolean));
|
||||
const unwantedSourceTags = new Set(['leech', 'marked', 'potential_leech']);
|
||||
const tagsToAdd: string[] = [];
|
||||
|
||||
for (const rawTag of deleteNoteInfo.tags ?? []) {
|
||||
const tag = rawTag.trim();
|
||||
if (!tag || targetTags.has(tag) || unwantedSourceTags.has(tag)) continue;
|
||||
targetTags.add(tag);
|
||||
tagsToAdd.push(tag);
|
||||
}
|
||||
|
||||
return tagsToAdd;
|
||||
}
|
||||
|
||||
private async resolveFieldGroupingCallback(): Promise<
|
||||
| ((data: {
|
||||
original: KikuDuplicateCardInfo;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type NoteUpdateWorkflowDeps,
|
||||
type NoteUpdateWorkflowNoteInfo,
|
||||
} from './note-update-workflow';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
|
||||
function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
@@ -203,3 +204,72 @@ test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word
|
||||
|
||||
assert.equal(receivedLeadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const sidebarContext = {
|
||||
source: 'subtitle-sidebar' as const,
|
||||
text: 'sidebar previous line',
|
||||
startTime: 10,
|
||||
endTime: 12,
|
||||
capturedAtMs: 123,
|
||||
};
|
||||
let audioContext: unknown = null;
|
||||
let imageContext: unknown = null;
|
||||
let miscInfoStartTime: number | undefined;
|
||||
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: 'sidebar previous line' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
MiscInfo: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: 'avif',
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.getCurrentSubtitleText = () => 'current primary line';
|
||||
harness.deps.getCurrentSubtitleStart = () => 20;
|
||||
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
||||
audioContext = context ?? null;
|
||||
return Buffer.from('audio');
|
||||
};
|
||||
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
|
||||
imageContext = context ?? null;
|
||||
return Buffer.from('image');
|
||||
};
|
||||
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
|
||||
miscInfoStartTime = startTimeSeconds;
|
||||
return `start:${startTimeSeconds}`;
|
||||
};
|
||||
(
|
||||
harness.deps as NoteUpdateWorkflowDeps & {
|
||||
consumeSubtitleMiningContext: () => typeof sidebarContext | null;
|
||||
}
|
||||
).consumeSubtitleMiningContext = () => sidebarContext;
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.fields.Sentence, 'sidebar previous line');
|
||||
assert.deepEqual(audioContext, sidebarContext);
|
||||
assert.deepEqual(imageContext, sidebarContext);
|
||||
assert.equal(miscInfoStartTime, 10);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
@@ -65,10 +66,14 @@ export interface NoteUpdateWorkflowDeps {
|
||||
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
|
||||
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
|
||||
generateAudioFilename: () => string;
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
generateAudio: (context?: SubtitleMiningContext) => Promise<Buffer | null>;
|
||||
generateImageFilename: () => string;
|
||||
generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
|
||||
generateImage: (
|
||||
animatedLeadInSeconds?: number,
|
||||
context?: SubtitleMiningContext,
|
||||
) => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
showOsdNotification: (message: string) => void;
|
||||
@@ -79,9 +84,62 @@ export interface NoteUpdateWorkflowDeps {
|
||||
logError: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
function normalizeSubtitleContextText(text: string): string {
|
||||
return text
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasUsableSubtitleContextTiming(context: SubtitleMiningContext): boolean {
|
||||
return (
|
||||
Number.isFinite(context.startTime) &&
|
||||
Number.isFinite(context.endTime) &&
|
||||
context.endTime > context.startTime
|
||||
);
|
||||
}
|
||||
|
||||
function subtitleContextMatchesSentence(contextText: string, noteSentence: string): boolean {
|
||||
const normalizedContext = normalizeSubtitleContextText(contextText);
|
||||
const normalizedSentence = normalizeSubtitleContextText(noteSentence);
|
||||
if (!normalizedContext || !normalizedSentence) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedContext === normalizedSentence ||
|
||||
normalizedContext.includes(normalizedSentence) ||
|
||||
normalizedSentence.includes(normalizedContext)
|
||||
);
|
||||
}
|
||||
|
||||
export class NoteUpdateWorkflow {
|
||||
constructor(private readonly deps: NoteUpdateWorkflowDeps) {}
|
||||
|
||||
private consumeMatchingSubtitleMiningContext(
|
||||
fields: Record<string, string>,
|
||||
sentenceField: string,
|
||||
configuredSentenceField?: string,
|
||||
): SubtitleMiningContext | null {
|
||||
const context = this.deps.consumeSubtitleMiningContext?.() ?? null;
|
||||
if (!context || !hasUsableSubtitleContextTiming(context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateFields = [
|
||||
sentenceField,
|
||||
configuredSentenceField,
|
||||
DEFAULT_ANKI_CONNECT_CONFIG.fields.sentence,
|
||||
];
|
||||
const noteSentence = candidateFields
|
||||
.map((fieldName) => (fieldName ? fields[fieldName.toLowerCase()] : undefined))
|
||||
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
if (!noteSentence || subtitleContextMatchesSentence(context.text, noteSentence)) {
|
||||
return context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
|
||||
this.deps.beginUpdateProgress('Updating card');
|
||||
try {
|
||||
@@ -121,8 +179,13 @@ export class NoteUpdateWorkflow {
|
||||
let updatePerformed = false;
|
||||
let miscInfoFilename: string | null = null;
|
||||
const sentenceField = sentenceCardConfig.sentenceField;
|
||||
const subtitleMiningContext = this.consumeMatchingSubtitleMiningContext(
|
||||
fields,
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
);
|
||||
|
||||
const currentSubtitleText = this.deps.getCurrentSubtitleText();
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
@@ -132,7 +195,7 @@ export class NoteUpdateWorkflow {
|
||||
if (config.media?.generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio();
|
||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
@@ -158,7 +221,10 @@ export class NoteUpdateWorkflow {
|
||||
try {
|
||||
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds);
|
||||
const imageBuffer = await this.deps.generateImage(
|
||||
animatedLeadInSeconds,
|
||||
subtitleMiningContext ?? undefined,
|
||||
);
|
||||
|
||||
if (imageBuffer) {
|
||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||
@@ -189,7 +255,7 @@ export class NoteUpdateWorkflow {
|
||||
if (config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
this.deps.getCurrentSubtitleStart(),
|
||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||
);
|
||||
const miscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
|
||||
@@ -104,6 +104,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||
assert.equal(config.subtitleStyle.primaryVisibleOnYomitanPopup, true);
|
||||
assert.equal(config.subtitleSidebar.enabled, true);
|
||||
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
|
||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||
@@ -545,6 +546,44 @@ test('parses subtitleStyle.autoPauseVideoOnYomitanPopup and warns on invalid val
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.primaryVisibleOnYomitanPopup and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"primaryVisibleOnYomitanPopup": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup, false);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"primaryVisibleOnYomitanPopup": "yes"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
DEFAULT_CONFIG.subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -2241,7 +2280,7 @@ test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(config.ankiConnect.knownWords.decks, {
|
||||
@@ -2300,7 +2339,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.ok(
|
||||
|
||||
@@ -8,6 +8,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: false,
|
||||
|
||||
@@ -35,9 +35,7 @@ function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
}
|
||||
|
||||
// DEFAULT_CONFIG leaves that intentionally do not have a curated
|
||||
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
|
||||
// includes these paths, but their inline comments fall back to an auto-
|
||||
// humanized key name instead of a written description.
|
||||
// CONFIG_OPTION_REGISTRY entry.
|
||||
//
|
||||
// Current intentional gaps:
|
||||
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
|
||||
@@ -49,6 +47,8 @@ function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
// an allowlist entry. Only allowlist a path when the registry is genuinely
|
||||
// the wrong surface for it.
|
||||
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
'keybindings',
|
||||
'subtitleStyle.backdropFilter',
|
||||
'subtitleStyle.backgroundColor',
|
||||
@@ -85,6 +85,13 @@ const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.WebkitTextStroke',
|
||||
'subtitleStyle.wordSpacing',
|
||||
'youtubeSubgen.ai.model',
|
||||
'youtubeSubgen.ai.systemPrompt',
|
||||
'youtubeSubgen.fixWithAi',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'youtubeSubgen.whisperModel',
|
||||
'youtubeSubgen.whisperThreads',
|
||||
'youtubeSubgen.whisperVadModel',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
|
||||
@@ -188,7 +188,7 @@ export function buildCoreConfigOptionRegistry(
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.controller.buttonIndices,
|
||||
description:
|
||||
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
|
||||
'Semantic button-name reference mapping used for debug output. Updating it does not rewrite existing raw binding descriptors.',
|
||||
},
|
||||
{
|
||||
path: 'controller.buttonIndices.select',
|
||||
|
||||
@@ -304,7 +304,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
||||
description:
|
||||
'Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.',
|
||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||
@@ -392,13 +392,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.refreshTtlHours',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.maxLoaded',
|
||||
kind: 'number',
|
||||
@@ -406,14 +399,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.evictionPolicy',
|
||||
kind: 'enum',
|
||||
enumValues: ['disable', 'delete'],
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary eviction is usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.profileScope',
|
||||
kind: 'enum',
|
||||
@@ -665,53 +650,5 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ai.requestTimeoutMs,
|
||||
description: 'Timeout in milliseconds for shared AI provider requests.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperBin',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
|
||||
description:
|
||||
'Legacy compatibility path kept for external subtitle fallback tools; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
|
||||
description:
|
||||
'Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperVadModel',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperVadModel,
|
||||
description:
|
||||
'Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.whisperThreads',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.youtubeSubgen.whisperThreads,
|
||||
description: 'Legacy thread tuning for subtitle fallback tooling; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.fixWithAi',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.youtubeSubgen.fixWithAi,
|
||||
description:
|
||||
'Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.ai.model',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.ai.model,
|
||||
description:
|
||||
'Optional model override for legacy subtitle fallback post-processing; not used by default.',
|
||||
},
|
||||
{
|
||||
path: 'youtubeSubgen.ai.systemPrompt',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.youtubeSubgen.ai.systemPrompt,
|
||||
description:
|
||||
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
description:
|
||||
'Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
description:
|
||||
'Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.hoverTokenColor',
|
||||
kind: 'string',
|
||||
|
||||
@@ -84,7 +84,7 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
|
||||
test('knownWords.highlightEnabled does not implicitly enable nPlusOne', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
});
|
||||
@@ -92,10 +92,13 @@ test('enables n+1 for existing configs with known-word highlighting enabled', ()
|
||||
applyAnkiConnectResolution(context);
|
||||
|
||||
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(
|
||||
context.resolved.ankiConnect.nPlusOne.enabled,
|
||||
DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
|
||||
test('explicit nPlusOne.enabled is respected regardless of highlightEnabled', () => {
|
||||
const { context } = makeContext({
|
||||
knownWords: { highlightEnabled: true },
|
||||
nPlusOne: { enabled: false },
|
||||
|
||||
@@ -758,8 +758,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
'Expected boolean.',
|
||||
);
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = true;
|
||||
} else {
|
||||
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnHover = resolved.subtitleStyle.autoPauseVideoOnHover;
|
||||
const fallbackSubtitleStyleAutoPauseVideoOnYomitanPopup =
|
||||
resolved.subtitleStyle.autoPauseVideoOnYomitanPopup;
|
||||
const fallbackSubtitleStylePrimaryVisibleOnYomitanPopup =
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup;
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
@@ -333,6 +335,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const primaryVisibleOnYomitanPopup = asBoolean(
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup,
|
||||
);
|
||||
if (primaryVisibleOnYomitanPopup !== undefined) {
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup = primaryVisibleOnYomitanPopup;
|
||||
} else if (
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup !== undefined
|
||||
) {
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup =
|
||||
fallbackSubtitleStylePrimaryVisibleOnYomitanPopup;
|
||||
warn(
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
(src.subtitleStyle as { primaryVisibleOnYomitanPopup?: unknown })
|
||||
.primaryVisibleOnYomitanPopup,
|
||||
resolved.subtitleStyle.primaryVisibleOnYomitanPopup,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const hoverTokenColor = asColor(
|
||||
(src.subtitleStyle as { hoverTokenColor?: unknown }).hoverTokenColor,
|
||||
);
|
||||
|
||||
@@ -128,6 +128,33 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle primaryVisibleOnYomitanPopup falls back on invalid value', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
primaryVisibleOnYomitanPopup: false,
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(valid.context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, false);
|
||||
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
primaryVisibleOnYomitanPopup: 'invalid' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.primaryVisibleOnYomitanPopup, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.primaryVisibleOnYomitanPopup' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle primaryDefaultMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
@@ -15,6 +15,8 @@ test('settings registry splits viewing into appearance and behavior categories',
|
||||
assert.equal(field('subtitleStyle.fontSize').category, 'appearance');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryDefaultMode').section, 'Subtitle Behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').category, 'behavior');
|
||||
assert.equal(field('subtitleStyle.primaryVisibleOnYomitanPopup').section, 'Subtitle Behavior');
|
||||
assert.equal(field('secondarySub.defaultMode').category, 'behavior');
|
||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||
@@ -28,7 +30,14 @@ test('settings registry splits viewing into appearance and behavior categories',
|
||||
assert.equal(field('mpv.profile').section, 'mpv Playback');
|
||||
assert.ok(
|
||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
fields.findIndex(
|
||||
(candidate) => candidate.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
) < fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ const PATH_ORDER = new Map<string, number>(
|
||||
'subtitleStyle.hoverTokenBackgroundColor',
|
||||
'subtitleStyle.css',
|
||||
'subtitleStyle.primaryDefaultMode',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.css',
|
||||
@@ -218,6 +219,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
@@ -251,6 +253,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup':
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
@@ -359,7 +363,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('subtitleStyle.secondary.')) {
|
||||
return { category: 'appearance', section: 'Secondary Subtitle Appearance' };
|
||||
}
|
||||
if (path === 'subtitleStyle.primaryDefaultMode') {
|
||||
if (
|
||||
path === 'subtitleStyle.primaryDefaultMode' ||
|
||||
path === 'subtitleStyle.primaryVisibleOnYomitanPopup'
|
||||
) {
|
||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||
}
|
||||
if (path.startsWith('subtitleStyle.')) {
|
||||
@@ -603,6 +610,7 @@ function isFeatureToggle(field: ConfigSettingsField): boolean {
|
||||
}
|
||||
|
||||
function fieldTypeRank(field: ConfigSettingsField): number {
|
||||
if (field.configPath === 'subtitleStyle.primaryVisibleOnYomitanPopup') return 2;
|
||||
if (field.control !== 'boolean') return 2;
|
||||
return isFeatureToggle(field) ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,17 @@ const TOP_LEVEL_SECTION_DESCRIPTION_BY_KEY = new Map(
|
||||
CONFIG_TEMPLATE_SECTIONS.map((section) => [String(section.key), section.description[0] ?? '']),
|
||||
);
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
const HIDDEN_TEMPLATE_PATHS = [
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
'youtubeSubgen.ai.model',
|
||||
'youtubeSubgen.ai.systemPrompt',
|
||||
'youtubeSubgen.fixWithAi',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'youtubeSubgen.whisperModel',
|
||||
'youtubeSubgen.whisperThreads',
|
||||
'youtubeSubgen.whisperVadModel',
|
||||
];
|
||||
|
||||
function normalizeCommentText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').replace(/\*\//g, '*\\/').trim();
|
||||
@@ -172,6 +183,9 @@ function renderSection(
|
||||
function createTemplateConfig(config: ResolvedConfig): ResolvedConfig {
|
||||
const templateConfig = deepCloneConfig(config);
|
||||
foldSubtitleCssManagedDefaults(templateConfig);
|
||||
for (const hiddenPath of HIDDEN_TEMPLATE_PATHS) {
|
||||
deleteValueAtPath(templateConfig, hiddenPath);
|
||||
}
|
||||
if (templateConfig.keybindings.length === 0) {
|
||||
templateConfig.keybindings = DEFAULT_KEYBINDINGS.map((binding) => ({
|
||||
key: binding.key,
|
||||
|
||||
@@ -71,7 +71,7 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
|
||||
assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000);
|
||||
|
||||
for (let attempt = 2; attempt <= 8; attempt += 1) {
|
||||
queue.markFailure('k2', `fail-${attempt}`, now);
|
||||
queue.markFailure('k2', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||
@@ -83,6 +83,52 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('anilist update queue ignores duplicate failures while retry is cooling down', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
const now = 1_700_000 * 1_000_000;
|
||||
queue.enqueue('k2', 'Backoff Demo', 2);
|
||||
queue.markFailure('k2', 'fail-1', now);
|
||||
|
||||
for (let attempt = 2; attempt <= 12; attempt += 1) {
|
||||
queue.markFailure('k2', `duplicate-${attempt}`, now + attempt);
|
||||
}
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
|
||||
pending: Array<{ attemptCount: number; lastError: string | null }>;
|
||||
deadLetter: Array<unknown>;
|
||||
};
|
||||
assert.equal(payload.pending[0]?.attemptCount, 1);
|
||||
assert.equal(payload.pending[0]?.lastError, 'fail-1');
|
||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
pending: 1,
|
||||
ready: 1,
|
||||
deadLetter: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('anilist update queue does not re-enqueue dead-lettered keys', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
const now = 1_700_000 * 1_000_000;
|
||||
queue.enqueue('k4', 'Dead Letter Demo', 4);
|
||||
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
||||
queue.markFailure('k4', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
queue.enqueue('k4', 'Dead Letter Demo', 4);
|
||||
|
||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
pending: 0,
|
||||
ready: 0,
|
||||
deadLetter: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('anilist update queue persists and reloads from disk', () => {
|
||||
const queueFile = createTempQueueFile();
|
||||
const loggerState = createLogger();
|
||||
|
||||
@@ -108,7 +108,8 @@ export function createAnilistUpdateQueue(
|
||||
|
||||
return {
|
||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||
const existing = pending.find((item) => item.key === key);
|
||||
const existing =
|
||||
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
@@ -147,6 +148,9 @@ export function createAnilistUpdateQueue(
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (item.attemptCount > 0 && item.nextAttemptAt > nowMs) {
|
||||
return;
|
||||
}
|
||||
item.attemptCount += 1;
|
||||
item.lastError = reason;
|
||||
if (item.attemptCount >= MAX_ATTEMPTS) {
|
||||
|
||||
@@ -133,3 +133,129 @@ test('createFieldGroupingOverlayRuntime callback restores hidden visible overlay
|
||||
assert.equal(visible, false);
|
||||
assert.deepEqual(visibilityTransitions, [true, false]);
|
||||
});
|
||||
|
||||
async function settleWithinMicrotasks<T>(
|
||||
promise: Promise<T>,
|
||||
attempts = 10,
|
||||
): Promise<T | 'timeout'> {
|
||||
let settled = false;
|
||||
let settledValue: T | undefined;
|
||||
void promise.then((value) => {
|
||||
settled = true;
|
||||
settledValue = value;
|
||||
});
|
||||
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
await Promise.resolve();
|
||||
if (settled) {
|
||||
return settledValue as T;
|
||||
}
|
||||
}
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku modal never acknowledges open', async () => {
|
||||
let resolver: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
const sends: Array<{
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
restoreOnModalClose?: string;
|
||||
preferModalWindow?: boolean;
|
||||
}> = [];
|
||||
const waitCalls: Array<{ modal: string; timeoutMs: number }> = [];
|
||||
const warnings: string[] = [];
|
||||
const closed: string[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
try {
|
||||
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => resolver,
|
||||
setResolver: (nextResolver) => {
|
||||
resolver = nextResolver;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) => {
|
||||
sends.push({
|
||||
channel,
|
||||
payload,
|
||||
restoreOnModalClose: runtimeOptions?.restoreOnModalClose,
|
||||
preferModalWindow: runtimeOptions?.preferModalWindow,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
waitCalls.push({ modal, timeoutMs });
|
||||
return false;
|
||||
},
|
||||
handleOverlayModalClosed: (modal) => {
|
||||
closed.push(modal);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
const request = {
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
};
|
||||
const pendingChoice = runtime.createFieldGroupingCallback()(request);
|
||||
const result = await settleWithinMicrotasks(pendingChoice);
|
||||
|
||||
assert.notEqual(result, 'timeout');
|
||||
assert.deepEqual(result, {
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
assert.equal(resolver, null);
|
||||
assert.deepEqual(
|
||||
sends.map(({ channel, restoreOnModalClose, preferModalWindow }) => ({
|
||||
channel,
|
||||
restoreOnModalClose,
|
||||
preferModalWindow,
|
||||
})),
|
||||
[
|
||||
{
|
||||
channel: 'kiku:field-grouping-request',
|
||||
restoreOnModalClose: 'kiku',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
{
|
||||
channel: 'kiku:field-grouping-request',
|
||||
restoreOnModalClose: 'kiku',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(waitCalls, [
|
||||
{ modal: 'kiku', timeoutMs: 1500 },
|
||||
{ modal: 'kiku', timeoutMs: 1500 },
|
||||
]);
|
||||
assert.deepEqual(warnings, [
|
||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
assert.deepEqual(closed, ['kiku']);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,6 +8,10 @@ interface WindowLike {
|
||||
};
|
||||
}
|
||||
|
||||
const KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS = 1500;
|
||||
const KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING =
|
||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.';
|
||||
|
||||
export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
@@ -15,10 +19,13 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<T>;
|
||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||
handleOverlayModalClosed?: (modal: T) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
sendToVisibleOverlay?: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
@@ -28,7 +35,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
@@ -37,7 +44,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
const sendToVisibleOverlay = (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
): boolean => {
|
||||
if (options.sendToVisibleOverlay) {
|
||||
const wasVisible = options.getVisibleOverlayVisible();
|
||||
@@ -58,6 +65,43 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
});
|
||||
};
|
||||
|
||||
const sendKikuFieldGroupingRequest = async (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
): Promise<boolean> => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
const sendOpen = (): boolean =>
|
||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: kikuModal,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
|
||||
if (!options.waitForModalOpen) {
|
||||
return sendOpen();
|
||||
}
|
||||
|
||||
if (!sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
if (await options.waitForModalOpen(kikuModal, KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
options.logWarn?.(KIKU_FIELD_GROUPING_MODAL_RETRY_WARNING);
|
||||
if (!sendOpen()) {
|
||||
options.handleOverlayModalClosed?.(kikuModal);
|
||||
return false;
|
||||
}
|
||||
|
||||
const opened = await options.waitForModalOpen(
|
||||
kikuModal,
|
||||
KIKU_FIELD_GROUPING_MODAL_OPEN_TIMEOUT_MS,
|
||||
);
|
||||
if (!opened) {
|
||||
options.handleOverlayModalClosed?.(kikuModal);
|
||||
}
|
||||
return opened;
|
||||
};
|
||||
|
||||
const createFieldGroupingCallback = (): ((
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
@@ -67,6 +111,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
sendKikuFieldGroupingRequest,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export function createFieldGroupingCallback(options: {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -21,10 +21,15 @@ export function createFieldGroupingCallback(options: {
|
||||
|
||||
const previousVisibleOverlay = options.getVisibleOverlayVisible();
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (options.getResolver() === finish) {
|
||||
options.setResolver(null);
|
||||
}
|
||||
@@ -36,25 +41,38 @@ export function createFieldGroupingCallback(options: {
|
||||
};
|
||||
|
||||
options.setResolver(finish);
|
||||
if (!options.sendRequestToVisibleOverlay(data)) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
void Promise.resolve(options.sendRequestToVisibleOverlay(data)).then(
|
||||
(sent) => {
|
||||
if (settled) return;
|
||||
if (!sent) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
}, 90000);
|
||||
},
|
||||
() => {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
}, 90000);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -630,6 +630,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);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionValue,
|
||||
SubtitleMiningContext,
|
||||
SubtitleSidebarSnapshot,
|
||||
SubtitlePosition,
|
||||
SubsyncManualRunRequest,
|
||||
@@ -95,6 +96,7 @@ export interface IpcServiceDeps {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (
|
||||
mediaId: number,
|
||||
@@ -175,6 +177,43 @@ interface IpcMainRegistrar {
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
|
||||
}
|
||||
|
||||
function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const source = record.source;
|
||||
const text = record.text;
|
||||
const startTime = record.startTime;
|
||||
const endTime = record.endTime;
|
||||
const capturedAtMs = record.capturedAtMs;
|
||||
|
||||
if (
|
||||
source !== 'subtitle-sidebar' ||
|
||||
typeof text !== 'string' ||
|
||||
text.trim().length === 0 ||
|
||||
typeof startTime !== 'number' ||
|
||||
typeof endTime !== 'number' ||
|
||||
!Number.isFinite(startTime) ||
|
||||
!Number.isFinite(endTime) ||
|
||||
endTime <= startTime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: SubtitleMiningContext = {
|
||||
source: 'subtitle-sidebar',
|
||||
text,
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
if (typeof capturedAtMs === 'number' && Number.isFinite(capturedAtMs)) {
|
||||
parsed.capturedAtMs = capturedAtMs;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
@@ -230,6 +269,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
recordSubtitleMiningContext?: (context: SubtitleMiningContext | null) => void;
|
||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (
|
||||
mediaId: number,
|
||||
@@ -257,6 +297,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
|
||||
quitApp: options.quitApp,
|
||||
toggleDevTools: () => {
|
||||
const mainWindow = options.getMainWindow();
|
||||
@@ -423,7 +464,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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -25,6 +25,7 @@ interface MpvClientLike {
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
currentSecondarySubText?: string;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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<string | undefined> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: T },
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
@@ -71,8 +72,10 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: 'kiku' as T,
|
||||
}),
|
||||
options.sendKikuFieldGroupingRequest
|
||||
? options.sendKikuFieldGroupingRequest(data)
|
||||
: options.sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: 'kiku' as T,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ interface YomitanTokenInput {
|
||||
surface: string;
|
||||
reading?: string;
|
||||
headword?: string;
|
||||
frequencyRank?: number;
|
||||
isNameMatch?: boolean;
|
||||
wordClasses?: string[];
|
||||
}
|
||||
@@ -57,6 +58,7 @@ function makeDepsFromYomitanTokens(
|
||||
startPos,
|
||||
endPos,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
frequencyRank: token.frequencyRank,
|
||||
wordClasses: token.wordClasses,
|
||||
};
|
||||
});
|
||||
@@ -4279,6 +4281,64 @@ test('tokenizeSubtitle keeps frequency for content-led merged token with trailin
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 5468);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'目の前',
|
||||
makeDepsFromYomitanTokens(
|
||||
[{ surface: '目の前', reading: 'めのまえ', headword: '目の前', frequencyRank: 581 }],
|
||||
{
|
||||
getFrequencyDictionaryEnabled: () => true,
|
||||
tokenizeWithMecab: async () => [
|
||||
{
|
||||
headword: '目',
|
||||
surface: '目',
|
||||
reading: 'メ',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '一般',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: 'の',
|
||||
surface: 'の',
|
||||
reading: 'ノ',
|
||||
startPos: 1,
|
||||
endPos: 2,
|
||||
partOfSpeech: PartOfSpeech.particle,
|
||||
pos1: '助詞',
|
||||
pos2: '連体化',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
{
|
||||
headword: '前',
|
||||
surface: '前',
|
||||
reading: 'マエ',
|
||||
startPos: 2,
|
||||
endPos: 3,
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '副詞可能',
|
||||
isMerged: false,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.[0]?.surface, '目の前');
|
||||
assert.equal(result.tokens?.[0]?.pos1, '名詞|助詞');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'第二走者',
|
||||
|
||||
@@ -70,9 +70,8 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
||||
if (parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Frequency highlighting should be conservative: if any merged component is excluded,
|
||||
// skip highlighting the whole token to avoid noisy merged fragments.
|
||||
return parts.some((part) => exclusions.has(part));
|
||||
|
||||
return parts.every((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||
@@ -227,6 +226,10 @@ function isFrequencyExcludedByPos(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isKanaOnlyMixedFunctionContentToken(token, pos1Exclusions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedPos1 = normalizePos1Tag(token.pos1);
|
||||
const hasPos1 = normalizedPos1.length > 0;
|
||||
const normalizedPos2 = normalizePos2Tag(token.pos2);
|
||||
@@ -564,6 +567,35 @@ function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean {
|
||||
return chars.length === 1 && isKanaChar(chars[0]!);
|
||||
}
|
||||
|
||||
function isKanaOnlyText(text: string | undefined): boolean {
|
||||
if (typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [...normalized].every(isKanaChar);
|
||||
}
|
||||
|
||||
function isKanaOnlyMixedFunctionContentToken(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (!isKanaOnlyText(token.surface)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
return (
|
||||
pos1Parts.length >= 2 &&
|
||||
pos1Parts.some((part) => pos1Exclusions.has(part)) &&
|
||||
pos1Parts.some((part) => !pos1Exclusions.has(part))
|
||||
);
|
||||
}
|
||||
|
||||
function isJlptEligibleToken(token: MergedToken): boolean {
|
||||
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
|
||||
return false;
|
||||
|
||||
+3
-20
@@ -14,12 +14,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`app-${today}.log`,
|
||||
),
|
||||
path.join('C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', `app-${today}.log`),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -33,13 +28,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join(
|
||||
'/home/tester',
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`app-${today}.log`,
|
||||
),
|
||||
path.join('/home/tester', '.config', 'SubMiner', 'logs', `app-${today}.log`),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -55,13 +44,7 @@ test('setLogRotation accepts numeric retention days', () => {
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join(
|
||||
'/home/tester',
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`app-${today}.log`,
|
||||
),
|
||||
path.join('/home/tester', '.config', 'SubMiner', 'logs', `app-${today}.log`),
|
||||
);
|
||||
assert.equal(process.env.SUBMINER_LOG_ROTATION, '14');
|
||||
} finally {
|
||||
|
||||
+19
-2
@@ -113,6 +113,7 @@ import type {
|
||||
SecondarySubMode,
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitleMiningContext,
|
||||
SubtitlePosition,
|
||||
UpdateChannel,
|
||||
WindowGeometry,
|
||||
@@ -730,8 +731,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
|
||||
const texthookerService = new Texthooker(() => {
|
||||
const config = getResolvedConfig();
|
||||
const characterDictionaryEnabled =
|
||||
config.subtitleStyle.nameMatchEnabled &&
|
||||
yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
||||
config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled();
|
||||
const knownWordColoringEnabled = getRuntimeBooleanOption(
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
@@ -908,6 +908,7 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
notifyAnilistTokenStoreWarning = (message: string) => {
|
||||
logger.warn(`[AniList] ${message}`);
|
||||
@@ -2181,6 +2182,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
overlayModalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
})(),
|
||||
@@ -4190,6 +4194,14 @@ const immersionTrackerStartupMainDeps: Parameters<
|
||||
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
|
||||
createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(),
|
||||
);
|
||||
const recordSubtitleMiningContext = (context: SubtitleMiningContext | null): void => {
|
||||
pendingSubtitleMiningContext = context;
|
||||
};
|
||||
const consumePendingSubtitleMiningContext = (): SubtitleMiningContext | null => {
|
||||
const context = pendingSubtitleMiningContext;
|
||||
pendingSubtitleMiningContext = null;
|
||||
return context;
|
||||
};
|
||||
const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
@@ -5153,6 +5165,7 @@ function initializeOverlayRuntime(): void {
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
@@ -5948,6 +5961,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
@@ -6198,6 +6212,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||
consumePendingSubtitleMiningContext,
|
||||
);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
showDesktopNotification,
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
|
||||
recordSubtitleMiningContext?: IpcDepsRuntimeOptions['recordSubtitleMiningContext'];
|
||||
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
||||
getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot'];
|
||||
@@ -273,6 +274,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
|
||||
recordSubtitleMiningContext: params.recordSubtitleMiningContext,
|
||||
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
||||
getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot,
|
||||
|
||||
@@ -804,6 +804,28 @@ test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||
assert.equal(await pending, true);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves true when modal acknowledgement arrives before waiter registration', async () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-request',
|
||||
{},
|
||||
{
|
||||
restoreOnModalClose: 'kiku',
|
||||
},
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('kiku');
|
||||
|
||||
assert.equal(await runtime.waitForModalOpen('kiku', 5), true);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves false on timeout', async () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createOverlayModalRuntimeService(
|
||||
): OverlayModalRuntime {
|
||||
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
|
||||
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
|
||||
const openedModals = new Set<OverlayHostedModal>();
|
||||
let modalActive = false;
|
||||
let mainWindowMousePassthroughForcedByModal = false;
|
||||
let mainWindowHiddenByModal = false;
|
||||
@@ -375,6 +376,7 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
|
||||
openedModals.delete(modal);
|
||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||
restoreVisibleOverlayOnModalClose.delete(modal);
|
||||
const modalWindow = deps.getModalWindow();
|
||||
@@ -392,6 +394,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
|
||||
if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
|
||||
openedModals.add(modal);
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
modalOpenWaiters.delete(modal);
|
||||
for (const resolve of waiters) {
|
||||
@@ -420,6 +423,10 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
||||
await new Promise<boolean>((resolve) => {
|
||||
if (openedModals.has(modal)) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const waiters = modalOpenWaiters.get(modal) ?? [];
|
||||
const finish = (opened: boolean): void => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -7,7 +7,7 @@ type FieldGroupingOverlayMainDeps<TModal extends string> = Omit<
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends st
|
||||
sendToVisibleOverlay: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal; preferModalWindow?: boolean },
|
||||
) => deps.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,13 +204,7 @@ test('detectInstalledMpvPlugin prefers Windows portable plugin and parses versio
|
||||
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.posix.join(root, 'home');
|
||||
const legacyPath = path.posix.join(
|
||||
homeDir,
|
||||
'.config',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer-loader.lua',
|
||||
);
|
||||
const legacyPath = path.posix.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
|
||||
fs.mkdirSync(path.posix.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(legacyPath, '-- legacy');
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -213,9 +213,7 @@ export async function launchWindowsMpv(
|
||||
const launchEnv =
|
||||
hasLogLevel || hasLogRotation
|
||||
? {
|
||||
...(hasLogLevel
|
||||
? { SUBMINER_LOG_LEVEL: pluginRuntimeConfig.logLevel }
|
||||
: {}),
|
||||
...(hasLogLevel ? { SUBMINER_LOG_LEVEL: pluginRuntimeConfig.logLevel } : {}),
|
||||
...(hasLogRotation
|
||||
? { SUBMINER_LOG_ROTATION: String(pluginRuntimeConfig.logRotation) }
|
||||
: {}),
|
||||
|
||||
+3
-2
@@ -55,6 +55,7 @@ import type {
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
SessionNumericSelectionStartPayload,
|
||||
SubtitleMiningContext,
|
||||
YoutubePickerOpenPayload,
|
||||
YoutubePickerResolveRequest,
|
||||
YoutubePickerResolveResult,
|
||||
@@ -262,8 +263,8 @@ const electronAPI: ElectronAPI = {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
|
||||
},
|
||||
|
||||
recordYomitanLookup: () => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup);
|
||||
recordYomitanLookup: (context?: SubtitleMiningContext | null) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.recordYomitanLookup, context ?? null);
|
||||
},
|
||||
|
||||
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
|
||||
|
||||
@@ -970,6 +970,89 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
||||
}
|
||||
});
|
||||
|
||||
test('yomitan popup visibility marks primary subtitle hover hold while enabled', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
(ctx.state as { primaryVisibleOnYomitanPopup?: boolean }).primaryVisibleOnYomitanPopup = true;
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
|
||||
const previousNode = (globalThis as { Node?: unknown }).Node;
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
const bodyClassList = createClassList();
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: () => {},
|
||||
},
|
||||
focus: () => {},
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
body: { classList: bodyClassList },
|
||||
querySelectorAll: () => [],
|
||||
querySelector: () => null,
|
||||
visibilityState: 'visible',
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
observe() {}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ELEMENT_NODE: 1,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: () => {},
|
||||
});
|
||||
|
||||
handlers.setupYomitanObserver();
|
||||
|
||||
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||
listener();
|
||||
}
|
||||
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), true);
|
||||
|
||||
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||
listener();
|
||||
}
|
||||
assert.equal(bodyClassList.contains('primary-sub-visible-on-yomitan-popup'), false);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||
configurable: true,
|
||||
value: previousMutationObserver,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||
}
|
||||
});
|
||||
|
||||
test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
isYomitanPopupVisible,
|
||||
isYomitanPopupIframe,
|
||||
} from '../yomitan-popup.js';
|
||||
@@ -44,10 +45,21 @@ export function createMouseHandlers(
|
||||
return typeof document !== 'undefined' && isYomitanPopupVisible(document);
|
||||
}
|
||||
|
||||
function syncPrimaryVisibleOnYomitanPopupClass(popupVisible: boolean): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
document.body?.classList?.toggle(
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
popupVisible && ctx.state.primaryVisibleOnYomitanPopup,
|
||||
);
|
||||
}
|
||||
|
||||
function syncPopupVisibilityState(assumeVisible = false): boolean {
|
||||
const popupVisible = assumeVisible || getPopupVisibilityFromDom();
|
||||
yomitanPopupVisible = popupVisible;
|
||||
ctx.state.yomitanPopupVisible = popupVisible;
|
||||
syncPrimaryVisibleOnYomitanPopupClass(popupVisible);
|
||||
return popupVisible;
|
||||
}
|
||||
|
||||
@@ -293,6 +305,7 @@ export function createMouseHandlers(
|
||||
|
||||
yomitanPopupVisible = false;
|
||||
ctx.state.yomitanPopupVisible = false;
|
||||
syncPrimaryVisibleOnYomitanPopupClass(false);
|
||||
popupPauseRequestId += 1;
|
||||
maybeResumeYomitanPopupPause();
|
||||
maybeResumeHoverPause();
|
||||
|
||||
@@ -113,6 +113,88 @@ test('findActiveSubtitleCueIndex prefers current subtitle timing over near-futur
|
||||
assert.equal(findActiveSubtitleCueIndex(cues, { text: 'previous', startTime: 231 }, 233, 0), 0);
|
||||
});
|
||||
|
||||
test('subtitle sidebar mining context resolves selected row cue timing', () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
Element?: unknown;
|
||||
Node?: unknown;
|
||||
window?: unknown;
|
||||
};
|
||||
const previousElement = globals.Element;
|
||||
const previousNode = globals.Node;
|
||||
const previousWindow = globals.window;
|
||||
|
||||
class FakeNode {
|
||||
parentElement: FakeElement | null = null;
|
||||
}
|
||||
class FakeElement extends FakeNode {
|
||||
dataset: Record<string, string> = {};
|
||||
|
||||
closest(selector: string) {
|
||||
return selector === '.subtitle-sidebar-item' ? this : null;
|
||||
}
|
||||
}
|
||||
|
||||
const row = new FakeElement();
|
||||
row.dataset.index = '1';
|
||||
const textNode = new FakeNode();
|
||||
textNode.parentElement = row;
|
||||
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: FakeNode });
|
||||
Object.defineProperty(globalThis, 'Element', { configurable: true, value: FakeElement });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getSelection: () => ({ anchorNode: textNode, focusNode: null }),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const state = createRendererState();
|
||||
state.subtitleSidebarModalOpen = true;
|
||||
state.subtitleSidebarCues = [
|
||||
{ startTime: 1, endTime: 2, text: 'current line' },
|
||||
{ startTime: 3, endTime: 5, text: 'sidebar previous line' },
|
||||
];
|
||||
const modal = createSubtitleSidebarModal(
|
||||
{
|
||||
dom: {
|
||||
overlay: { classList: createClassList() },
|
||||
subtitleSidebarModal: {
|
||||
classList: createClassList(),
|
||||
setAttribute: () => {},
|
||||
style: { setProperty: () => {} },
|
||||
addEventListener: () => {},
|
||||
},
|
||||
subtitleSidebarContent: {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ width: 420 }),
|
||||
style: { setProperty: () => {} },
|
||||
},
|
||||
subtitleSidebarClose: { addEventListener: () => {} },
|
||||
subtitleSidebarStatus: { textContent: '' },
|
||||
subtitleSidebarList: createListStub(),
|
||||
},
|
||||
state,
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
},
|
||||
);
|
||||
|
||||
const context = modal.getSubtitleSidebarMiningContext();
|
||||
|
||||
assert.equal(context?.source, 'subtitle-sidebar');
|
||||
assert.equal(context?.text, 'sidebar previous line');
|
||||
assert.equal(context?.startTime, 3);
|
||||
assert.equal(context?.endTime, 5);
|
||||
assert.equal(typeof context?.capturedAtMs, 'number');
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'Element', { configurable: true, value: previousElement });
|
||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('applySidebarCssDeclarations clears declarations removed by config reload', () => {
|
||||
const removed: string[] = [];
|
||||
const style = {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
|
||||
import type {
|
||||
SubtitleCue,
|
||||
SubtitleData,
|
||||
SubtitleMiningContext,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
import {
|
||||
@@ -201,6 +206,7 @@ export function createSubtitleSidebarModal(
|
||||
let subtitleSidebarFocusedWithin = false;
|
||||
let subtitleSidebarYomitanPopupVisible = false;
|
||||
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
@@ -213,9 +219,75 @@ export function createSubtitleSidebarModal(
|
||||
function clearSidebarInteractionState(): void {
|
||||
subtitleSidebarHovered = false;
|
||||
subtitleSidebarFocusedWithin = false;
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
syncSidebarInteractionState();
|
||||
}
|
||||
|
||||
function findCueIndexFromNode(node: Node | null): number | null {
|
||||
if (!node || typeof Element === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
const row = element?.closest<HTMLElement>('.subtitle-sidebar-item') ?? null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const index = Number.parseInt(row.dataset.index ?? '', 10);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= ctx.state.subtitleSidebarCues.length) {
|
||||
return null;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function rememberLookupCueFromTarget(target: EventTarget | null): void {
|
||||
if (typeof Node === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
const index = findCueIndexFromNode(target);
|
||||
if (index === null) {
|
||||
return;
|
||||
}
|
||||
lastSubtitleSidebarLookupCueIndex = index;
|
||||
}
|
||||
|
||||
function getSubtitleSidebarMiningContext(): SubtitleMiningContext | null {
|
||||
if (!ctx.state.subtitleSidebarModalOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = window.getSelection?.() ?? null;
|
||||
const selectionIndex =
|
||||
findCueIndexFromNode(selection?.anchorNode ?? null) ??
|
||||
findCueIndexFromNode(selection?.focusNode ?? null);
|
||||
const index =
|
||||
selectionIndex ??
|
||||
(lastSubtitleSidebarLookupCueIndex >= 0 ? lastSubtitleSidebarLookupCueIndex : null);
|
||||
if (index === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cue = ctx.state.subtitleSidebarCues[index];
|
||||
if (
|
||||
!cue ||
|
||||
!Number.isFinite(cue.startTime) ||
|
||||
!Number.isFinite(cue.endTime) ||
|
||||
cue.endTime <= cue.startTime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'subtitle-sidebar',
|
||||
text: cue.text,
|
||||
startTime: cue.startTime,
|
||||
endTime: cue.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(message: string): void {
|
||||
ctx.dom.subtitleSidebarStatus.textContent = message;
|
||||
}
|
||||
@@ -653,6 +725,12 @@ export function createSubtitleSidebarModal(
|
||||
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
|
||||
ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS;
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('pointerover', (event) => {
|
||||
rememberLookupCueFromTarget(event.target);
|
||||
});
|
||||
ctx.dom.subtitleSidebarList.addEventListener('focusin', (event) => {
|
||||
rememberLookupCueFromTarget(event.target);
|
||||
});
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
|
||||
subtitleSidebarHovered = true;
|
||||
syncSidebarInteractionState();
|
||||
@@ -677,6 +755,9 @@ export function createSubtitleSidebarModal(
|
||||
});
|
||||
ctx.dom.subtitleSidebarContent.addEventListener('mouseleave', () => {
|
||||
subtitleSidebarHovered = false;
|
||||
if (!subtitleSidebarFocusedWithin) {
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
}
|
||||
syncSidebarInteractionState();
|
||||
if (ctx.state.isOverSubtitleSidebar) {
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
@@ -700,6 +781,7 @@ export function createSubtitleSidebarModal(
|
||||
}
|
||||
|
||||
subtitleSidebarFocusedWithin = false;
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
syncSidebarInteractionState();
|
||||
if (ctx.state.isOverSubtitleSidebar) {
|
||||
restoreEmbeddedSidebarPassthrough();
|
||||
@@ -736,5 +818,6 @@ export function createSubtitleSidebarModal(
|
||||
},
|
||||
handleSubtitleUpdated,
|
||||
seekToCue,
|
||||
getSubtitleSidebarMiningContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ registerModalOpenHandlers();
|
||||
registerKeyboardCommandHandlers();
|
||||
registerYomitanLookupListener(window, () => {
|
||||
runGuarded('yomitan:lookup', () => {
|
||||
window.electronAPI.recordYomitanLookup();
|
||||
window.electronAPI.recordYomitanLookup(subtitleSidebarModal.getSubtitleSidebarMiningContext());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ export type RendererState = {
|
||||
preserveSubtitleLineBreaks: boolean;
|
||||
autoPauseVideoOnSubtitleHover: boolean;
|
||||
autoPauseVideoOnYomitanPopup: boolean;
|
||||
primaryVisibleOnYomitanPopup: boolean;
|
||||
frequencyDictionaryEnabled: boolean;
|
||||
frequencyDictionaryTopX: number;
|
||||
frequencyDictionaryMode: 'single' | 'banded';
|
||||
@@ -225,6 +226,7 @@ export function createRendererState(): RendererState {
|
||||
preserveSubtitleLineBreaks: false,
|
||||
autoPauseVideoOnSubtitleHover: false,
|
||||
autoPauseVideoOnYomitanPopup: false,
|
||||
primaryVisibleOnYomitanPopup: true,
|
||||
frequencyDictionaryEnabled: false,
|
||||
frequencyDictionaryTopX: 1000,
|
||||
frequencyDictionaryMode: 'single',
|
||||
|
||||
@@ -694,6 +694,10 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#subtitleContainer.primary-sub-hidden {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -1205,6 +1205,12 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
);
|
||||
assert.match(primaryHoverVisibleBlock, /opacity:\s*1;/);
|
||||
|
||||
const primaryHoverYomitanPopupVisibleBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.primary-sub-visible-on-yomitan-popup #subtitleContainer.primary-sub-hover',
|
||||
);
|
||||
assert.match(primaryHoverYomitanPopupVisibleBlock, /opacity:\s*1;/);
|
||||
|
||||
const secondaryEmbeddedHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
'body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SubtitleRendererStyleConfig,
|
||||
} from '../types';
|
||||
import type { RendererContext } from './context';
|
||||
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
|
||||
|
||||
type FrequencyRenderSettings = {
|
||||
enabled: boolean;
|
||||
@@ -259,6 +260,13 @@ function applySubtitleCssDeclarations(
|
||||
);
|
||||
}
|
||||
|
||||
function syncPrimaryVisibleOnYomitanPopupClass(ctx: RendererContext): void {
|
||||
document.body?.classList?.toggle(
|
||||
PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS,
|
||||
ctx.state.yomitanPopupVisible && ctx.state.primaryVisibleOnYomitanPopup,
|
||||
);
|
||||
}
|
||||
|
||||
function pickInlineStyleDeclarations(
|
||||
declarations: Record<string, unknown>,
|
||||
includedKeys: ReadonlySet<string>,
|
||||
@@ -805,6 +813,8 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
ctx.state.preserveSubtitleLineBreaks = style.preserveLineBreaks ?? false;
|
||||
ctx.state.autoPauseVideoOnSubtitleHover = style.autoPauseVideoOnHover ?? false;
|
||||
ctx.state.autoPauseVideoOnYomitanPopup = style.autoPauseVideoOnYomitanPopup ?? false;
|
||||
ctx.state.primaryVisibleOnYomitanPopup = style.primaryVisibleOnYomitanPopup ?? true;
|
||||
syncPrimaryVisibleOnYomitanPopupClass(ctx);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n1-color', jlptColors.N1);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n2-color', jlptColors.N2);
|
||||
ctx.dom.subtitleRoot.style.setProperty('--subtitle-jlpt-n3-color', jlptColors.N3);
|
||||
|
||||
@@ -9,6 +9,7 @@ export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter';
|
||||
export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave';
|
||||
export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command';
|
||||
export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup';
|
||||
export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup';
|
||||
|
||||
export function registerYomitanLookupListener(
|
||||
target: EventTarget = window,
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ResolvedSubtitleSidebarConfig,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitleMiningContext,
|
||||
SubtitlePosition,
|
||||
SubtitleSidebarSnapshot,
|
||||
SubtitleRendererStyleConfig,
|
||||
@@ -413,7 +414,7 @@ export interface ElectronAPI {
|
||||
onSubtitleAss: (callback: (assText: string) => void) => void;
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
openYomitanSettings: () => void;
|
||||
recordYomitanLookup: () => void;
|
||||
recordYomitanLookup: (context?: SubtitleMiningContext | null) => void;
|
||||
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabStatus: () => Promise<MecabStatus>;
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface SubtitleStyleConfig {
|
||||
preserveLineBreaks?: boolean;
|
||||
autoPauseVideoOnHover?: boolean;
|
||||
autoPauseVideoOnYomitanPopup?: boolean;
|
||||
primaryVisibleOnYomitanPopup?: boolean;
|
||||
hoverTokenColor?: string;
|
||||
hoverTokenBackgroundColor?: string;
|
||||
nameMatchEnabled?: boolean;
|
||||
@@ -217,6 +218,14 @@ export interface SubtitleSidebarSnapshot {
|
||||
config: SubtitleSidebarSnapshotConfig;
|
||||
}
|
||||
|
||||
export interface SubtitleMiningContext {
|
||||
source: 'subtitle-sidebar';
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
capturedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleHoverTokenPayload {
|
||||
tokenIndex: number | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user