mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-03 07:21:34 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
176edd67f1
|
|||
| 4d65dec340 | |||
|
6607c333bc
|
|||
|
b2bbf1ae12
|
|||
| b204d4dd6e | |||
| 89ed675935 |
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|
||||||
|
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
## Field Grouping (Kiku)
|
||||||
|
|
||||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -314,7 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | --------------------------------------------- |
|
||||||
| Sentence | Both cards' sentences kept as grouped entries |
|
| Sentence | Both cards' sentences kept as grouped entries |
|
||||||
| Audio | Both cards' `[sound:...]` entries kept |
|
| Audio | Both cards' `[sound:...]` entries kept |
|
||||||
| Image | Both cards' images kept |
|
| Image | Both cards' images kept |
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `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`. |
|
| `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. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
@@ -556,7 +556,7 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
|
},
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1077,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
|
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
||||||
|
|
||||||
|
### Word Card Type
|
||||||
|
|
||||||
|
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||||
|
|
||||||
|
| Value | Flag set |
|
||||||
|
| ----------------------------- | ----------------------- |
|
||||||
|
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||||
|
| `click` | `IsClickCard` |
|
||||||
|
| `sentence` | `IsSentenceCard` |
|
||||||
|
| `audio` | `IsAudioCard` |
|
||||||
|
| `none` | none; flags left as-is |
|
||||||
|
|
||||||
|
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1168,7 +1186,7 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||||
|
|
||||||
@@ -1229,7 +1247,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `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.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
@@ -1540,7 +1558,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| 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 `""`) |
|
| `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 `""`) |
|
| `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"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
|||||||
|
|
||||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||||
|
|
||||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0",
|
"version": "0.19.1",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
+12
-47
@@ -1,57 +1,22 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
- **Anki Maturity Known-Word Highlighting**
|
- Kiku/Lapis Word Card Type Setting
|
||||||
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
|
- A new setting (Mining/Anki > Kiku/Lapis Features > "Word Card Type") lets you choose which card-type flag gets marked on Kiku/Lapis word cards, including a click-card option that SubMiner couldn't set before.
|
||||||
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
|
- Handy if you only want click cards flagged instead of the default word-and-sentence marking.
|
||||||
- **Cross-Machine Sync for Stats & Watch History**
|
- Choosing a card type now clears any other flags automatically, so a note can't end up marked as two types at once.
|
||||||
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
|
|
||||||
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
|
|
||||||
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
|
|
||||||
- **History Menu After Playback**
|
|
||||||
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
|
|
||||||
- **Delete Entire Library Titles from Stats**
|
|
||||||
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
|
|
||||||
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
|
|
||||||
- **TsukiHime English Subtitle Downloads**
|
|
||||||
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Configurable Clipboard-Video Shortcut**
|
|
||||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **AniList Season Matching**
|
- Yomitan Popup on macOS
|
||||||
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
|
- Fixed the popup going unresponsive after mining a card — clicks outside it no longer leak through to mpv, and the overlay no longer flickers hidden and shown.
|
||||||
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
|
- Scrolling over the popup now scrolls its definitions instead of seeking the video.
|
||||||
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
|
- YouTube Playlist Links
|
||||||
- **Subtitle Highlighting Accuracy**
|
- Opening a video from a playlist URL (like a Watch Later link with `list=`/`index=`) no longer times out while loading subtitles, metadata, or playback info.
|
||||||
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
|
|
||||||
- **AnkiConnect Proxy Port Conflicts**
|
|
||||||
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
|
|
||||||
- **AppImage Crash Notification on Quit**
|
|
||||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
|
||||||
- **Startup Playback Pause Timing**
|
|
||||||
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
|
|
||||||
- **Stats Library Cover After Relinking**
|
|
||||||
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
|
|
||||||
- **Faster Stats Deletes and Vocabulary Tab**
|
|
||||||
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
|
|
||||||
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
|
|
||||||
- **Settings Validation and Stats Server Hardening**
|
|
||||||
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
|
|
||||||
- **Rofi Prompt Spacing**
|
|
||||||
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- feat(anki): add configurable word card type for Kiku/Lapis by @ksyasuda in #175
|
||||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
- fix(overlay): keep Yomitan popup interactive on macOS/Windows by @ksyasuda in #177
|
||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- fix(youtube): prevent playlist URLs from stalling yt-dlp probes by @ksyasuda in #180
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
|
||||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
|
||||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
type WordCardKind,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
|
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
|
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||||
|
this.resolveFieldName(availableFieldNames, preferredName),
|
||||||
if (cardKind === 'word-and-sentence') {
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
);
|
||||||
if (!wordAndSentenceFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== sentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedAudioFlags = Array.from(
|
|
||||||
new Set(
|
|
||||||
audioFlagNames
|
|
||||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
|
||||||
.filter((name): name is string => Boolean(name)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
|
||||||
if (audioFlagName) {
|
|
||||||
updatedFields[audioFlagName] = 'x';
|
|
||||||
}
|
|
||||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
|
||||||
updatedFields[extraAudioFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
if (cardKind !== 'word-and-sentence') return;
|
applyCardKindFlagFields(
|
||||||
|
updatedFields,
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
cardKind,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
|
function resolverFor(availableFieldNames: string[]) {
|
||||||
|
return (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||||
|
|
||||||
|
test('flags the requested card kind and clears the others', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches flag fields case-insensitively', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'click',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'audio',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not blank the target flag it just set', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||||
|
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||||
|
*/
|
||||||
|
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||||
|
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||||
|
click: 'IsClickCard',
|
||||||
|
sentence: 'IsSentenceCard',
|
||||||
|
audio: 'IsAudioCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||||
|
'word-and-sentence',
|
||||||
|
'click',
|
||||||
|
'sentence',
|
||||||
|
'audio',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||||
|
* when the note type actually carries the matching flag field, so plain note types keep
|
||||||
|
* their fields untouched.
|
||||||
|
*/
|
||||||
|
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||||
|
|
||||||
|
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||||
|
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||||
|
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||||
|
* never ends up claiming to be two kinds of card at once.
|
||||||
|
*/
|
||||||
|
export function applyCardKindFlagFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
cardKind: CardKind,
|
||||||
|
resolveFieldName: (preferredName: string) => string | null,
|
||||||
|
): void {
|
||||||
|
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||||
|
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetFlag) {
|
||||||
|
updatedFields[targetFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||||
|
if (kind === cardKind) continue;
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== targetFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||||
|
|
||||||
|
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
Expression: '単語',
|
||||||
|
Sentence: '',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsClickCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the configured word card kind', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the word card kind is "none"', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'none',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the default kind for an unrecognized setting', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'bogus' as never,
|
||||||
|
}),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||||
|
const note: NoteFieldValueInfo = {
|
||||||
|
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||||
|
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
flagField,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'sentence',
|
||||||
|
}),
|
||||||
|
'sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides a differently flagged word card', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
CARD_KIND_FLAG_FIELDS,
|
||||||
|
DEFAULT_WORD_CARD_KIND,
|
||||||
|
resolveWordCardKindSetting,
|
||||||
|
} from './card-kinds';
|
||||||
|
|
||||||
|
const log = createLogger('anki').child('integration.note-fields');
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldMarkWordAndSentenceCard(
|
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||||
noteInfo: NoteFieldValueInfo,
|
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
const warnedMissingFlagFields = new Set<CardKind>();
|
||||||
if (wordAndSentenceValue === null) {
|
|
||||||
return false;
|
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||||
|
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||||
|
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wordAndSentenceValue.trim().length > 0) {
|
warnedMissingFlagFields.add(wordCardKind);
|
||||||
return true;
|
log.warn(
|
||||||
}
|
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||||
return (
|
|
||||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||||
|
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||||
|
*/
|
||||||
|
export function resolveWordCardKind(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: {
|
||||||
|
lapisEnabled: boolean;
|
||||||
|
kikuEnabled: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
},
|
||||||
|
): CardKind | null {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||||
|
if (wordCardKind === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||||
|
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||||
|
if (flagValue === null) {
|
||||||
|
// Note type has no flag field for the configured kind.
|
||||||
|
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (flagValue.trim().length > 0) {
|
||||||
|
return wordCardKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||||
|
(fieldName) =>
|
||||||
|
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||||
|
);
|
||||||
|
return alreadyExplicitCard ? null : wordCardKind;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import type { CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
assert.equal(cardKind, 'word-and-sentence');
|
applyCardKindFlagFields(
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
updatedFields,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
cardKind,
|
||||||
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'click',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: 'x' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'none',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||||
|
...(config.lapisKiku ?? {}),
|
||||||
|
},
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
|||||||
patch.isKiku !== undefined
|
patch.isKiku !== undefined
|
||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
|
lapisKiku:
|
||||||
|
patch.lapisKiku !== undefined
|
||||||
|
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||||
|
: this.config.lapisKiku,
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": { "wordCardKind": "click" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||||
|
assert.equal(service.getWarnings().length, 0);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fieldGrouping: 'disabled',
|
fieldGrouping: 'disabled',
|
||||||
deleteDuplicateInAuto: true,
|
deleteDuplicateInAuto: true,
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'word-and-sentence',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jimaku: {
|
jimaku: {
|
||||||
apiBaseUrl: 'https://jimaku.cc',
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_VALUES,
|
NOTIFICATION_TYPE_VALUES,
|
||||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||||
description: 'Note type name used by Lapis sentence cards.',
|
description: 'Note type name used by Lapis sentence cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: WORD_CARD_KINDS,
|
||||||
|
enumLabels: {
|
||||||
|
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||||
|
click: 'Click card (IsClickCard)',
|
||||||
|
sentence: 'Sentence card (IsSentenceCard)',
|
||||||
|
audio: 'Audio card (IsAudioCard)',
|
||||||
|
none: 'Leave card type flags untouched',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
description:
|
||||||
|
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.metadata.pattern',
|
path: 'ankiConnect.metadata.pattern',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
applyAnkiKikuResolution(context);
|
applyAnkiKikuResolution(context);
|
||||||
|
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
|||||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...context.resolved.ankiConnect.lapisKiku,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAnkiLapisKikuResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!isObject(ankiConnect.lapisKiku)) {
|
||||||
|
if (ankiConnect.lapisKiku !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku',
|
||||||
|
ankiConnect.lapisKiku,
|
||||||
|
context.resolved.ankiConnect.lapisKiku,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
if (wordCardKind === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWordCardKind(wordCardKind)) {
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
wordCardKind,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
}
|
||||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||||
'ankiConnect.isLapis.sentenceCardModel':
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind':
|
||||||
|
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||||
'subtitleStyle.css':
|
'subtitleStyle.css':
|
||||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleStyle.secondary.css':
|
'subtitleStyle.secondary.css':
|
||||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media Capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (
|
||||||
|
path.startsWith('ankiConnect.isKiku.') ||
|
||||||
|
path.startsWith('ankiConnect.isLapis.') ||
|
||||||
|
path.startsWith('ankiConnect.lapisKiku.')
|
||||||
|
) {
|
||||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||||
path === 'mpv.aniskipEnabled' ||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
|
|||||||
@@ -2454,6 +2454,80 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.IsClickCard, 'x');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
resolveSecondarySubtitleTextFromSidecar,
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
} from '../secondary-subtitle-sidecar.js';
|
} from '../secondary-subtitle-sidecar.js';
|
||||||
import {
|
import {
|
||||||
applyStatsWordAndSentenceCardFields,
|
applyStatsWordCardFields,
|
||||||
createStatsMiningContext,
|
createStatsMiningContext,
|
||||||
getStatsDirectMiningAudioFieldNames,
|
getStatsDirectMiningAudioFieldNames,
|
||||||
getStatsWordMiningAudioFieldName,
|
getStatsWordMiningAudioFieldName,
|
||||||
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MediaGenerator } from '../../../media-generator.js';
|
import type { MediaGenerator } from '../../../media-generator.js';
|
||||||
import type { AnkiConnectConfig } from '../../../types.js';
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
|
||||||
|
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
|
||||||
import { createLogger } from '../../../logger.js';
|
import { createLogger } from '../../../logger.js';
|
||||||
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
|
|||||||
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyStatsWordAndSentenceCardFields(
|
export function applyStatsWordCardFields(
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
): void {
|
): void {
|
||||||
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
if (!noteInfo) return;
|
||||||
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
const cardKind = resolveWordCardKind(noteInfo, {
|
||||||
if (!wordAndSentenceFlag) return;
|
lapisEnabled: ankiConfig.isLapis?.enabled === true,
|
||||||
|
kikuEnabled: ankiConfig.isKiku?.enabled === true,
|
||||||
|
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
|
||||||
|
});
|
||||||
|
if (!cardKind) return;
|
||||||
|
|
||||||
fields[wordAndSentenceFlag] = 'x';
|
applyCardKindFlagFields(fields, cardKind, (preferredName) =>
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
resolveStatsNoteFieldName(noteInfo, preferredName),
|
||||||
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
);
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatsDirectMiningAudioFieldNames(
|
export function getStatsDirectMiningAudioFieldNames(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
|
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
|
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ function normalizeMaxHeight(maxHeight: number | undefined): number {
|
|||||||
|
|
||||||
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
|
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
|
||||||
return [
|
return [
|
||||||
'--no-playlist',
|
YTDLP_SINGLE_VIDEO_ARG,
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--force-ipv4',
|
'--force-ipv4',
|
||||||
'--retries',
|
'--retries',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -85,15 +85,23 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeYoutubeVideoMetadata(
|
export function buildYoutubeMetadataProbeArgs(targetUrl: string): string[] {
|
||||||
targetUrl: string,
|
return [
|
||||||
): Promise<YoutubeVideoMetadata | null> {
|
YTDLP_SINGLE_VIDEO_ARG,
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
|
||||||
'--dump-single-json',
|
'--dump-single-json',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
targetUrl,
|
targetUrl,
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeYoutubeVideoMetadata(
|
||||||
|
targetUrl: string,
|
||||||
|
): Promise<YoutubeVideoMetadata | null> {
|
||||||
|
const { stdout } = await runCapture(
|
||||||
|
getYoutubeYtDlpCommand(),
|
||||||
|
buildYoutubeMetadataProbeArgs(targetUrl),
|
||||||
|
);
|
||||||
let info: YtDlpYoutubeMetadata;
|
let info: YtDlpYoutubeMetadata;
|
||||||
try {
|
try {
|
||||||
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||||
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||||
@@ -85,17 +85,18 @@ function runCapture(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildYoutubePlaybackResolveArgs(targetUrl: string, format: string): string[] {
|
||||||
|
return [YTDLP_SINGLE_VIDEO_ARG, '--get-url', '--no-warnings', '-f', format, targetUrl];
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveYoutubePlaybackUrl(
|
export async function resolveYoutubePlaybackUrl(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
format = DEFAULT_PLAYBACK_FORMAT,
|
format = DEFAULT_PLAYBACK_FORMAT,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
const { stdout } = await runCapture(
|
||||||
'--get-url',
|
getYoutubeYtDlpCommand(),
|
||||||
'--no-warnings',
|
buildYoutubePlaybackResolveArgs(targetUrl, format),
|
||||||
'-f',
|
);
|
||||||
format,
|
|
||||||
targetUrl,
|
|
||||||
]);
|
|
||||||
const playbackUrl =
|
const playbackUrl =
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from './track-probe';
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
import {
|
import {
|
||||||
convertYoutubeTimedTextToVtt,
|
convertYoutubeTimedTextToVtt,
|
||||||
isYoutubeTimedTextExtension,
|
isYoutubeTimedTextExtension,
|
||||||
@@ -126,14 +126,14 @@ function pickLatestSubtitleFileForLanguage(
|
|||||||
return candidates[0] ?? null;
|
return candidates[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDownloadArgs(input: {
|
export function buildDownloadArgs(input: {
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
outputTemplate: string;
|
outputTemplate: string;
|
||||||
sourceLanguages: string[];
|
sourceLanguages: string[];
|
||||||
includeAutoSubs: boolean;
|
includeAutoSubs: boolean;
|
||||||
includeManualSubs: boolean;
|
includeManualSubs: boolean;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const args = ['--skip-download', '--no-warnings'];
|
const args = [YTDLP_SINGLE_VIDEO_ARG, '--skip-download', '--no-warnings'];
|
||||||
if (input.includeAutoSubs) {
|
if (input.includeAutoSubs) {
|
||||||
args.push('--write-auto-subs');
|
args.push('--write-auto-subs');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from '../../../types';
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -111,12 +111,15 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
|
|||||||
|
|
||||||
export type { YoutubeTrackOption };
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
|
export function buildYoutubeTrackProbeArgs(targetUrl: string): string[] {
|
||||||
|
return [YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', targetUrl];
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
const { stdout } = await runCapture(
|
||||||
'--dump-single-json',
|
getYoutubeYtDlpCommand(),
|
||||||
'--no-warnings',
|
buildYoutubeTrackProbeArgs(targetUrl),
|
||||||
targetUrl,
|
);
|
||||||
]);
|
|
||||||
const trimmedStdout = stdout.trim();
|
const trimmedStdout = stdout.trim();
|
||||||
if (!trimmedStdout) {
|
if (!trimmedStdout) {
|
||||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import path from 'node:path';
|
|||||||
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
||||||
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp expands `list=`/`index=` URL params into the whole playlist unless told not to, which
|
||||||
|
* makes single-video extraction hang (e.g. a full Watch Later list) until our timeouts fire.
|
||||||
|
* Every yt-dlp invocation targeting one video must include this.
|
||||||
|
*/
|
||||||
|
export const YTDLP_SINGLE_VIDEO_ARG = '--no-playlist';
|
||||||
|
|
||||||
function resolveFromPath(commandName: string): string | null {
|
function resolveFromPath(commandName: string): string | null {
|
||||||
if (!process.env.PATH) {
|
if (!process.env.PATH) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
|
||||||
|
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
|
||||||
|
import { buildDownloadArgs } from './track-download';
|
||||||
|
import { buildYoutubeTrackProbeArgs } from './track-probe';
|
||||||
|
import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
|
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
|
||||||
|
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
|
||||||
|
const PLAYLIST_URL = 'https://www.youtube.com/watch?v=LKfWC6CgFng&list=WL&index=3';
|
||||||
|
|
||||||
|
const cases: Array<{ name: string; args: string[] }> = [
|
||||||
|
{ name: 'track probe', args: buildYoutubeTrackProbeArgs(PLAYLIST_URL) },
|
||||||
|
{ name: 'metadata probe', args: buildYoutubeMetadataProbeArgs(PLAYLIST_URL) },
|
||||||
|
{ name: 'playback resolve', args: buildYoutubePlaybackResolveArgs(PLAYLIST_URL, 'b') },
|
||||||
|
{
|
||||||
|
name: 'subtitle download',
|
||||||
|
args: buildDownloadArgs({
|
||||||
|
targetUrl: PLAYLIST_URL,
|
||||||
|
outputTemplate: '/tmp/out.%(ext)s',
|
||||||
|
sourceLanguages: ['ja'],
|
||||||
|
includeAutoSubs: true,
|
||||||
|
includeManualSubs: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => {
|
||||||
|
assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { name, args } of cases) {
|
||||||
|
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
|
||||||
|
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
|
||||||
|
assert.equal(args.at(-1), PLAYLIST_URL);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||||
|
config.ankiConnect.lapisKiku.wordCardKind = 'click';
|
||||||
config.logging.level = 'debug';
|
config.logging.level = 'debug';
|
||||||
config.logging.rotation = 14;
|
config.logging.rotation = 14;
|
||||||
config.logging.files.mpv = true;
|
config.logging.files.mpv = true;
|
||||||
@@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'logging.rotation',
|
'logging.rotation',
|
||||||
'logging.files.mpv',
|
'logging.files.mpv',
|
||||||
@@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
},
|
},
|
||||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||||
isKiku: { fieldGrouping: 'manual' },
|
isKiku: { fieldGrouping: 'manual' },
|
||||||
|
lapisKiku: { wordCardKind: 'click' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.ok(calls.includes('invalidate:tokens'));
|
assert.ok(calls.includes('invalidate:tokens'));
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
|
|||||||
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
||||||
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
||||||
}
|
}
|
||||||
|
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
|
||||||
|
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(patch).length > 0 ? patch : null;
|
return Object.keys(patch).length > 0 ? patch : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getSubsyncStatusNotificationOptions,
|
||||||
getYoutubeFlowStatusNotificationOptions,
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
} from './configured-status-notification';
|
} from './configured-status-notification';
|
||||||
@@ -158,6 +159,64 @@ test('notifyConfiguredStatus can suppress desktop delivery for progress ticks',
|
|||||||
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subsync progress keeps the osd spinner frame but strips it from the overlay card', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
for (const frame of ['|', '/', '-', '\\']) {
|
||||||
|
const message = `Subsync: syncing ${frame}`;
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (osdMessage) => {
|
||||||
|
calls.push(`osd:${osdMessage}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(
|
||||||
|
`overlay:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
getSubsyncStatusNotificationOptions(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Subsync: syncing /',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (osdMessage) => {
|
||||||
|
calls.push(`osd:${osdMessage}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
getSubsyncStatusNotificationOptions('Subsync: syncing /'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['osd:Subsync: syncing /']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync result notifications keep their message intact', () => {
|
||||||
|
assert.equal(
|
||||||
|
getSubsyncStatusNotificationOptions('Subtitle synchronized with ffsubsync').overlayBody,
|
||||||
|
'Subtitle synchronized with ffsubsync',
|
||||||
|
);
|
||||||
|
const failure = getSubsyncStatusNotificationOptions('ffsubsync synchronization failed: boom');
|
||||||
|
assert.equal(failure.variant, 'error');
|
||||||
|
assert.equal(failure.overlayBody, 'ffsubsync synchronization failed: boom');
|
||||||
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ConfiguredStatusNotificationDeps {
|
|||||||
|
|
||||||
export interface ConfiguredStatusNotificationOptions {
|
export interface ConfiguredStatusNotificationOptions {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** Overrides the overlay card body (the OSD/desktop paths keep the raw message). */
|
||||||
|
overlayBody?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
variant?: OverlayNotificationPayload['variant'];
|
variant?: OverlayNotificationPayload['variant'];
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
@@ -31,6 +33,23 @@ export function getPlaybackFeedbackNotificationOptions(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSubsyncStatusNotificationOptions(
|
||||||
|
message: string,
|
||||||
|
): ConfiguredStatusNotificationOptions {
|
||||||
|
const syncing = message.startsWith('Subsync: syncing');
|
||||||
|
const failed = message.toLowerCase().includes('failed');
|
||||||
|
return {
|
||||||
|
id: 'subsync-status',
|
||||||
|
title: 'Subsync',
|
||||||
|
// The overlay card renders its own animated spinner, so drop the ASCII
|
||||||
|
// spinner frame that the OSD path still needs.
|
||||||
|
overlayBody: syncing ? message.replace(/\s+[|/\-\\]$/, '') : message,
|
||||||
|
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||||
|
persistent: syncing,
|
||||||
|
desktop: !syncing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getYoutubeFlowStatusNotificationOptions(
|
export function getYoutubeFlowStatusNotificationOptions(
|
||||||
message: string,
|
message: string,
|
||||||
): ConfiguredStatusNotificationOptions {
|
): ConfiguredStatusNotificationOptions {
|
||||||
@@ -74,7 +93,7 @@ export function notifyConfiguredStatus(
|
|||||||
deps.showOverlayNotification({
|
deps.showOverlayNotification({
|
||||||
id: options.id,
|
id: options.id,
|
||||||
title: options.title ?? 'SubMiner',
|
title: options.title ?? 'SubMiner',
|
||||||
body: message,
|
body: options.overlayBody ?? message,
|
||||||
variant: options.variant ?? 'info',
|
variant: options.variant ?? 'info',
|
||||||
persistent: options.persistent ?? false,
|
persistent: options.persistent ?? false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,8 +336,8 @@ test('tick only writes interaction state on change', () => {
|
|||||||
state.active = active;
|
state.active = active;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tickLinuxOverlayPointerInteraction(deps); // off→on
|
tickLinuxOverlayPointerInteraction(deps, 'linux'); // off→on
|
||||||
tickLinuxOverlayPointerInteraction(deps); // no change
|
tickLinuxOverlayPointerInteraction(deps, 'linux'); // no change
|
||||||
assert.deepEqual(calls, [true]);
|
assert.deepEqual(calls, [true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ test('tick reapplies an unchanged inactive state when the window passthrough sta
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, [false]);
|
assert.deepEqual(calls, [false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,7 +363,7 @@ test('tick does not flip state when suspended (returns null)', () => {
|
|||||||
shouldSuspend: () => true,
|
shouldSuspend: () => true,
|
||||||
setInteractionActive: (active) => calls.push(active),
|
setInteractionActive: (active) => calls.push(active),
|
||||||
});
|
});
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,10 +379,26 @@ test('tick clears active hover while a separate SubMiner window suppresses overl
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.active = true;
|
state.active = true;
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, [false]);
|
assert.deepEqual(calls, [false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tick never clears interaction state on macOS/Windows, where renderer hover owns it', () => {
|
||||||
|
for (const platform of ['darwin', 'win32'] as const) {
|
||||||
|
const calls: boolean[] = [];
|
||||||
|
// Pointer is off the measured subtitle rect (e.g. sitting on a Yomitan popup) while the
|
||||||
|
// renderer has marked the overlay interactive.
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
||||||
|
getInteractionActive: () => true,
|
||||||
|
setInteractionActive: (active) => calls.push(active),
|
||||||
|
});
|
||||||
|
|
||||||
|
tickLinuxOverlayPointerInteraction(deps, platform);
|
||||||
|
assert.deepEqual(calls, [], `expected no interaction writes on ${platform}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
||||||
const calls: boolean[] = [];
|
const calls: boolean[] = [];
|
||||||
const { deps } = makeDeps({
|
const { deps } = makeDeps({
|
||||||
@@ -391,7 +407,7 @@ test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns
|
|||||||
setInteractionActive: (active) => calls.push(active),
|
setInteractionActive: (active) => calls.push(active),
|
||||||
});
|
});
|
||||||
|
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,16 @@ export function resolveDesiredOverlayInteractive(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void {
|
export function tickLinuxOverlayPointerInteraction(
|
||||||
|
deps: LinuxOverlayPointerInteractionDeps,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): void {
|
||||||
|
// Linux-only. Windows/macOS drive interaction state from renderer hover (setIgnoreMouseEvents),
|
||||||
|
// which knows about Yomitan popups and modals that sit off the measured subtitle rects. This
|
||||||
|
// cursor poll only hit-tests those rects, so running it elsewhere would clear interaction state
|
||||||
|
// (and re-enable window passthrough) whenever a measurement lands while the pointer is on a
|
||||||
|
// popup, swallowing popup clicks and scroll.
|
||||||
|
if (platform !== 'linux') return;
|
||||||
if (deps.shouldUseInputShape?.()) return;
|
if (deps.shouldUseInputShape?.()) return;
|
||||||
const desired = resolveDesiredOverlayInteractive(deps);
|
const desired = resolveDesiredOverlayInteractive(deps);
|
||||||
if (desired === null) return;
|
if (desired === null) return;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
|
|||||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getSubsyncStatusNotificationOptions,
|
||||||
getYoutubeFlowStatusNotificationOptions,
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
type ConfiguredStatusNotificationOptions,
|
type ConfiguredStatusNotificationOptions,
|
||||||
@@ -195,15 +196,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSubsyncStatusNotification(message: string): void {
|
function showSubsyncStatusNotification(message: string): void {
|
||||||
const syncing = message.startsWith('Subsync: syncing');
|
showConfiguredStatusNotification(message, getSubsyncStatusNotificationOptions(message));
|
||||||
const failed = message.toLowerCase().includes('failed');
|
|
||||||
showConfiguredStatusNotification(message, {
|
|
||||||
id: 'subsync-status',
|
|
||||||
title: 'Subsync',
|
|
||||||
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
|
||||||
persistent: syncing,
|
|
||||||
desktop: !syncing,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showYoutubeFlowStatusNotification(message: string): void {
|
function showYoutubeFlowStatusNotification(message: string): void {
|
||||||
|
|||||||
@@ -827,6 +827,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
};
|
};
|
||||||
|
|
||||||
function tickLinuxOverlayPointerInteractionNow(): void {
|
function tickLinuxOverlayPointerInteractionNow(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { AiFeatureConfig } from './integrations';
|
|||||||
import type { NotificationType } from './notification';
|
import type { NotificationType } from './notification';
|
||||||
import type { NPlusOneMatchMode } from './subtitle';
|
import type { NPlusOneMatchMode } from './subtitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card that a Kiku/Lapis note generates. The note types mark this with mutually
|
||||||
|
* exclusive `Is...Card` flag fields, so only one kind may be flagged per note.
|
||||||
|
*/
|
||||||
|
export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||||
|
|
||||||
|
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||||
|
export type WordCardKind = CardKind | 'none';
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -115,4 +124,7 @@ export interface AnkiConnectConfig {
|
|||||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto?: boolean;
|
deleteDuplicateInAuto?: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku?: {
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AnkiConnectConfig } from './anki';
|
import type { AnkiConnectConfig, WordCardKind } from './anki';
|
||||||
import type {
|
import type {
|
||||||
AiConfig,
|
AiConfig,
|
||||||
AiFeatureConfig,
|
AiFeatureConfig,
|
||||||
@@ -285,6 +285,9 @@ export interface ResolvedConfig {
|
|||||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto: boolean;
|
deleteDuplicateInAuto: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
shortcuts: Required<ShortcutsConfig>;
|
shortcuts: Required<ShortcutsConfig>;
|
||||||
secondarySub: Required<SecondarySubConfig>;
|
secondarySub: Required<SecondarySubConfig>;
|
||||||
|
|||||||
Reference in New Issue
Block a user