Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8b0ef662bc
|
|||
| 0a58c20ad7 | |||
| 38ddb29aa0 | |||
| 8b9a70c5a6 | |||
| 48a084914a | |||
| a042b04357 | |||
| 35ca2afc6f | |||
| b14f977e33 | |||
| eef4500599 | |||
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 | |||
|
4b10e85053
|
|||
| c942a2cf2d | |||
| f65afa6046 | |||
|
389d8e06e0
|
|||
|
0008b55b70
|
@@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
@@ -16,6 +17,8 @@ coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
/subminer
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
### Fixed
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
### Added
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
### Fixed
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
subminer -H # browse local watch history (replay / next episode / browse)
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 436 B |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 549 B |
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: logs
|
||||
|
||||
- Fixed log filenames to use the user's local date so log export includes the current logs instead of stale prior-day logs around UTC midnight.
|
||||
- Expanded log export redaction to mask common PII and secrets, including IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, compound token/key/password fields, and signed YouTube media URL query strings.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: branding
|
||||
|
||||
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Improved YouTube card media generation by sending safer ffmpeg request options for resolved streams and skipping stale stream maps, including cached YouTube files.
|
||||
- Added `youtube.mediaCache.mode` with `direct` and `background` modes so YouTube card audio/image extraction can optionally use a background yt-dlp media cache when direct stream extraction is unreliable; background mode now announces cache download start/readiness through queued overlay/OSD notifications, creates text-only cards while the cache downloads, queues media updates for the mined note IDs, fills audio/image fields once the cached file is ready, and caps background downloads at `youtube.mediaCache.maxHeight` 720p by default while honoring height changes between downloads.
|
||||
- Cleaned stale YouTube background media cache files on startup and before new background downloads, and stopped in-flight background downloads when switching back to direct mode.
|
||||
- Hardened YouTube media cache downloads with IPv4/extractor retry flags and made failed background downloads notify users and clear queued media updates instead of leaving them pending silently.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -568,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
|
||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
**Fixed**
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
**Added**
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
**Fixed**
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
**Changed**
|
||||
|
||||
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
@@ -1538,7 +1540,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
|
||||
|
||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||
|
||||
@@ -61,6 +61,23 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
||||
```
|
||||
|
||||
## Watch History
|
||||
|
||||
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
|
||||
|
||||
```bash
|
||||
subminer -H # fzf history browser
|
||||
subminer -R -H # rofi history browser
|
||||
```
|
||||
|
||||
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
||||
|
||||
- **Replay last watched** — replays the most recently watched episode
|
||||
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
|
||||
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
|
||||
|
||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -105,6 +122,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
@@ -559,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -568,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -74,23 +74,27 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
||||
- Select different primary and secondary tracks
|
||||
- Retry track loading if the auto-load failed or picked the wrong track
|
||||
|
||||
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
|
||||
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
|
||||
card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ------ | -------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
@@ -101,13 +105,13 @@ Background cache downloads use IPv4 and retry flags to reduce YouTube throttling
|
||||
```jsonc
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
}
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
@@ -119,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
| Option | Type | Description |
|
||||
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
|
||||
// (see the build banner in package.json), but the repo typechecks with plain
|
||||
// tsc which has no bun type definitions.
|
||||
declare module 'bun:sqlite' {
|
||||
export interface RunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number | bigint;
|
||||
}
|
||||
|
||||
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
|
||||
all(...params: ParamsType): ReturnType[];
|
||||
get(...params: ParamsType): ReturnType | undefined;
|
||||
run(...params: ParamsType): RunResult;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
constructor(
|
||||
filename: string,
|
||||
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
);
|
||||
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
run(sql: string, ...params: unknown[]): RunResult;
|
||||
close(throwOnError?: boolean): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import {
|
||||
collectVideos,
|
||||
findRofiTheme,
|
||||
formatPickerLaunchError,
|
||||
showFzfMenu,
|
||||
showRofiMenu,
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveImmersionDbPath,
|
||||
sortVideosByEpisode,
|
||||
type HistorySeriesEntry,
|
||||
} from '../history.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
return;
|
||||
}
|
||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||
}
|
||||
|
||||
function showRofiIndexMenu(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||
const hasIcons = icons.some(Boolean);
|
||||
if (hasIcons) rofiArgs.push('-show-icons');
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
}
|
||||
if (hasIcons) {
|
||||
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
|
||||
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
|
||||
}
|
||||
const lines = labels.map((label, index) =>
|
||||
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
|
||||
);
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const out = (result.stdout || '').trim();
|
||||
if (!out) return -1;
|
||||
const idx = Number.parseInt(out, 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function showFzfIndexMenu(labels: string[], prompt: string): number {
|
||||
const lines = labels.map((label, index) => `${index}\t${label}`);
|
||||
const result = spawnSync(
|
||||
'fzf',
|
||||
[
|
||||
'--ansi',
|
||||
'--reverse',
|
||||
'--ignore-case',
|
||||
`--prompt=${prompt}: `,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2..',
|
||||
],
|
||||
{
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const picked = (result.stdout || '').trim();
|
||||
const tab = picked.indexOf('\t');
|
||||
if (tab === -1) return -1;
|
||||
const idx = Number.parseInt(picked.slice(0, tab), 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function pickIndex(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
useRofi: boolean,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
if (labels.length === 0) return -1;
|
||||
return useRofi
|
||||
? showRofiIndexMenu(labels, prompt, themePath, icons)
|
||||
: showFzfIndexMenu(labels, prompt);
|
||||
}
|
||||
|
||||
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
|
||||
const { parsedSeason, parsedEpisode } = entry.lastWatched;
|
||||
if (parsedEpisode === null) return '';
|
||||
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
|
||||
}
|
||||
|
||||
function formatSeriesLabel(entry: HistorySeriesEntry): string {
|
||||
const episodeLabel = formatEpisodeLabel(entry);
|
||||
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
|
||||
}
|
||||
|
||||
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
|
||||
const { args, scriptPath } = context;
|
||||
const videos = sortVideosByEpisode(collectVideos(dir, false));
|
||||
if (videos.length === 0) {
|
||||
fail(`No video files found in: ${dir}`);
|
||||
}
|
||||
const selected = args.useRofi
|
||||
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
|
||||
: showFzfMenu(videos);
|
||||
return selected || null;
|
||||
}
|
||||
|
||||
function browseEpisodes(
|
||||
entry: HistorySeriesEntry,
|
||||
context: LauncherCommandContext,
|
||||
themePath: string | null,
|
||||
): string | null {
|
||||
const { args } = context;
|
||||
const seasons = listSeasonDirs(entry.seriesRoot);
|
||||
let dir = entry.seriesRoot;
|
||||
|
||||
if (seasons.length > 1) {
|
||||
const idx = pickIndex(
|
||||
seasons.map((season) => season.name),
|
||||
`${entry.displayName} — Season`,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
);
|
||||
if (idx < 0) return null;
|
||||
dir = seasons[idx]!.path;
|
||||
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
|
||||
dir = seasons[0]!.path;
|
||||
}
|
||||
|
||||
return pickEpisodeFromDir(dir, context);
|
||||
}
|
||||
|
||||
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
|
||||
const dbPath = resolveImmersionDbPath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fail(`Watch history database not found: ${dbPath}`);
|
||||
}
|
||||
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
const series = groupHistoryBySeries(rows);
|
||||
if (series.length === 0) {
|
||||
fail('No local watch history found (or watched directories are not accessible).');
|
||||
}
|
||||
|
||||
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
|
||||
|
||||
const coverPaths = args.useRofi
|
||||
? materializeCoverArt(
|
||||
dbPath,
|
||||
series.map((seriesEntry) => seriesEntry.coverBlobHash),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
const seriesIcons = series.map((seriesEntry) =>
|
||||
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
|
||||
);
|
||||
|
||||
const seriesIdx = pickIndex(
|
||||
series.map(formatSeriesLabel),
|
||||
'Watch History',
|
||||
args.useRofi,
|
||||
themePath,
|
||||
seriesIcons,
|
||||
);
|
||||
if (seriesIdx < 0) return null;
|
||||
const entry = series[seriesIdx]!;
|
||||
|
||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const nextEpisode = findNextEpisode(lastPath);
|
||||
|
||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||
if (lastExists) {
|
||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||
}
|
||||
if (nextEpisode) {
|
||||
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
||||
}
|
||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
actions.map((action) => action.label),
|
||||
entry.displayName,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
actions.map(() => entryIcon),
|
||||
);
|
||||
if (actionIdx < 0) return null;
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
case 'next':
|
||||
return nextEpisode;
|
||||
case 'browse':
|
||||
return browseEpisodes(entry, context, themePath);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ function createContext(): LauncherCommandContext {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -198,6 +198,7 @@ export function createDefaultArgs(
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -231,6 +232,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.history === true) parsed.history = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
|
||||
@@ -64,6 +64,7 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { withReadonlyWalRetry } from './history-db.js';
|
||||
|
||||
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
|
||||
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
|
||||
|
||||
export function getDefaultCoverCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
|
||||
}
|
||||
|
||||
export function detectImageExtension(blob: Buffer): string {
|
||||
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
||||
return '.png';
|
||||
}
|
||||
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
||||
return '.jpg';
|
||||
}
|
||||
if (
|
||||
blob.length >= 12 &&
|
||||
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
blob.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
function findCachedCover(cacheDir: string, hash: string): string | null {
|
||||
for (const ext of COVER_EXTENSIONS) {
|
||||
const candidate = path.join(cacheDir, `${hash}${ext}`);
|
||||
try {
|
||||
if (fs.statSync(candidate).size > 0) return candidate;
|
||||
} catch {
|
||||
// not cached with this extension
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryCoverBlobs(
|
||||
dbPath: string,
|
||||
hashes: string[],
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): Map<string, Buffer> {
|
||||
const blobs = new Map<string, Buffer>();
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasBlobTable = db
|
||||
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
|
||||
.get();
|
||||
if (!hasBlobTable) return blobs;
|
||||
|
||||
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
|
||||
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
|
||||
);
|
||||
for (const hash of hashes) {
|
||||
const row = stmt.get(hash);
|
||||
if (row?.cover_blob && row.cover_blob.length > 0) {
|
||||
blobs.set(hash, Buffer.from(row.cover_blob));
|
||||
}
|
||||
}
|
||||
return blobs;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeCoverHash(hash: string | null | undefined): hash is string {
|
||||
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures cover art blobs referenced by hash exist as image files in the cache
|
||||
* directory, extracting missing ones from the stats database. Returns a map of
|
||||
* blob hash to on-disk image path for every cover that could be materialized.
|
||||
*/
|
||||
export function materializeCoverArt(
|
||||
dbPath: string,
|
||||
hashes: Array<string | null | undefined>,
|
||||
cacheDir: string = getDefaultCoverCacheDir(),
|
||||
): Map<string, string> {
|
||||
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
|
||||
const resolved = new Map<string, string>();
|
||||
if (wanted.length === 0) return resolved;
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const hash of wanted) {
|
||||
const cached = findCachedCover(cacheDir, hash);
|
||||
if (cached) {
|
||||
resolved.set(hash, cached);
|
||||
} else {
|
||||
missing.push(hash);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) return resolved;
|
||||
|
||||
let blobs: Map<string, Buffer>;
|
||||
try {
|
||||
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
if (blobs.size === 0) return resolved;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
for (const [hash, blob] of blobs) {
|
||||
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
|
||||
try {
|
||||
fs.writeFileSync(target, blob);
|
||||
resolved.set(hash, target);
|
||||
} catch {
|
||||
// cache write failure just means no icon for this entry
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import type { HistoryVideoRow } from './history-types.js';
|
||||
import { resolvePathMaybe } from './util.js';
|
||||
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const root = readLauncherMainConfigObject();
|
||||
const tracking =
|
||||
root?.immersionTracking &&
|
||||
typeof root.immersionTracking === 'object' &&
|
||||
!Array.isArray(root.immersionTracking)
|
||||
? (root.immersionTracking as Record<string, unknown>)
|
||||
: null;
|
||||
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
|
||||
if (configured) return resolvePathMaybe(configured);
|
||||
|
||||
const configDir = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
return path.join(configDir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
interface RawHistoryRow {
|
||||
video_id: number;
|
||||
source_path: string | null;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
last_watched_ms: number | bigint | null;
|
||||
cover_blob_hash: string | null;
|
||||
}
|
||||
|
||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
||||
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
|
||||
}
|
||||
|
||||
export function withReadonlyWalRetry<T>(
|
||||
dbPath: string,
|
||||
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
|
||||
): T {
|
||||
try {
|
||||
return query({ readonly: true });
|
||||
} catch (error) {
|
||||
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
|
||||
return query({ readwrite: true, create: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
|
||||
if (!isWalModeSqliteDatabase(dbPath)) return false;
|
||||
const code =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code ?? '')
|
||||
: '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const text = `${code} ${message}`.toLowerCase();
|
||||
return (
|
||||
text.includes('readonly') ||
|
||||
text.includes('read-only') ||
|
||||
text.includes('attempt to write a readonly database') ||
|
||||
text.includes('sqlite_cantopen') ||
|
||||
text.includes('unable to open database file')
|
||||
);
|
||||
}
|
||||
|
||||
function isWalModeSqliteDatabase(dbPath: string): boolean {
|
||||
const header = Buffer.alloc(20);
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(dbPath, 'r');
|
||||
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
|
||||
}
|
||||
|
||||
function tableExists(db: Database, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
function readHistoryRows(
|
||||
dbPath: string,
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): HistoryVideoRow[] {
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasMediaArt = tableExists(db, 'imm_media_art');
|
||||
const coverSelect = hasMediaArt
|
||||
? `COALESCE(
|
||||
ma.cover_blob_hash,
|
||||
(SELECT ma2.cover_blob_hash
|
||||
FROM imm_media_art ma2
|
||||
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
|
||||
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
|
||||
LIMIT 1)
|
||||
) AS cover_blob_hash`
|
||||
: 'NULL AS cover_blob_hash';
|
||||
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
|
||||
const rows = db
|
||||
.query<RawHistoryRow>(
|
||||
`
|
||||
SELECT
|
||||
v.video_id,
|
||||
v.source_path,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
||||
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
|
||||
${coverSelect}
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
${coverJoin}
|
||||
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
||||
GROUP BY v.video_id
|
||||
ORDER BY last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows
|
||||
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
|
||||
.map((row) => ({
|
||||
videoId: row.video_id,
|
||||
sourcePath: row.source_path!,
|
||||
parsedTitle: row.parsed_title,
|
||||
parsedSeason: row.parsed_season,
|
||||
parsedEpisode: row.parsed_episode,
|
||||
animeTitle: row.anime_title,
|
||||
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
||||
coverBlobHash: row.cover_blob_hash,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
||||
import { collectVideos } from './picker.js';
|
||||
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
|
||||
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
|
||||
|
||||
export function seasonNumberFromDirName(name: string): number | null {
|
||||
const match = name.trim().match(SEASON_DIR_PATTERN);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[1]!, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function resolveSeriesRoot(filePath: string): string {
|
||||
const parent = path.dirname(filePath);
|
||||
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
export function groupHistoryBySeries(
|
||||
rows: HistoryVideoRow[],
|
||||
existsFn: (candidate: string) => boolean = fs.existsSync,
|
||||
): HistorySeriesEntry[] {
|
||||
const byRoot = new Map<string, HistorySeriesEntry>();
|
||||
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||
|
||||
for (const row of sorted) {
|
||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
||||
const existing = byRoot.get(seriesRoot);
|
||||
if (existing) {
|
||||
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
|
||||
existing.coverBlobHash = row.coverBlobHash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existsFn(seriesRoot)) continue;
|
||||
const displayName =
|
||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
||||
byRoot.set(seriesRoot, {
|
||||
seriesRoot,
|
||||
displayName,
|
||||
lastWatched: row,
|
||||
coverBlobHash: row.coverBlobHash,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values());
|
||||
}
|
||||
|
||||
function compareNatural(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function sortVideosByEpisode(videos: string[]): string[] {
|
||||
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
|
||||
parsed.sort((a, b) => {
|
||||
if (a.info.episode !== null && b.info.episode !== null) {
|
||||
const seasonA = a.info.season ?? 0;
|
||||
const seasonB = b.info.season ?? 0;
|
||||
if (seasonA !== seasonB) return seasonA - seasonB;
|
||||
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
|
||||
}
|
||||
return compareNatural(a.video, b.video);
|
||||
});
|
||||
return parsed.map((entry) => entry.video);
|
||||
}
|
||||
|
||||
function dirContainsVideo(dir: string): boolean {
|
||||
return collectVideos(dir, true).length > 0;
|
||||
}
|
||||
|
||||
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(seriesRoot, entry.name),
|
||||
season: seasonNumberFromDirName(entry.name),
|
||||
}))
|
||||
.filter((entry) => dirContainsVideo(entry.path));
|
||||
|
||||
dirs.sort((a, b) => {
|
||||
if (a.season !== null && b.season !== null && a.season !== b.season) {
|
||||
return a.season - b.season;
|
||||
}
|
||||
return compareNatural(a.name, b.name);
|
||||
});
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
|
||||
const seriesRoot = resolveSeriesRoot(resolvedLast);
|
||||
if (seriesRoot === dir) return null;
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
||||
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
|
||||
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
|
||||
return nextSeason[0] ?? null;
|
||||
}
|
||||
|
||||
export function findNextEpisode(lastPath: string): string | null {
|
||||
const resolvedLast = path.resolve(lastPath);
|
||||
const dir = path.dirname(resolvedLast);
|
||||
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
||||
const idx = episodes.indexOf(resolvedLast);
|
||||
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
|
||||
} else {
|
||||
const lastInfo = parseMediaInfo(resolvedLast);
|
||||
if (lastInfo.episode !== null) {
|
||||
const candidate = episodes.find((episode) => {
|
||||
const info = parseMediaInfo(episode);
|
||||
return info.episode !== null && info.episode > lastInfo.episode!;
|
||||
});
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface HistoryVideoRow {
|
||||
videoId: number;
|
||||
sourcePath: string;
|
||||
parsedTitle: string | null;
|
||||
parsedSeason: number | null;
|
||||
parsedEpisode: number | null;
|
||||
animeTitle: string | null;
|
||||
lastWatchedMs: number;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface HistorySeriesEntry {
|
||||
seriesRoot: string;
|
||||
displayName: string;
|
||||
lastWatched: HistoryVideoRow;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface SeasonDirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
season: number | null;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
detectImageExtension,
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
isReadonlyWalRetryError,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveSeriesRoot,
|
||||
seasonNumberFromDirName,
|
||||
sortVideosByEpisode,
|
||||
type HistoryVideoRow,
|
||||
} from './history.js';
|
||||
|
||||
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
|
||||
return {
|
||||
videoId: 1,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
|
||||
parsedTitle: 'Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
animeTitle: null,
|
||||
lastWatchedMs: 1000,
|
||||
coverBlobHash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('seasonNumberFromDirName detects common season directory names', () => {
|
||||
assert.equal(seasonNumberFromDirName('Season-1'), 1);
|
||||
assert.equal(seasonNumberFromDirName('Season 2'), 2);
|
||||
assert.equal(seasonNumberFromDirName('S03'), 3);
|
||||
assert.equal(seasonNumberFromDirName('season_04'), 4);
|
||||
assert.equal(seasonNumberFromDirName('Specials'), null);
|
||||
assert.equal(seasonNumberFromDirName('Show Name'), null);
|
||||
});
|
||||
|
||||
test('resolveSeriesRoot skips season directories', () => {
|
||||
assert.equal(
|
||||
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
|
||||
'/media/anime/Show',
|
||||
);
|
||||
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries keeps most recent entry per series root', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
|
||||
parsedEpisode: 2,
|
||||
lastWatchedMs: 3000,
|
||||
}),
|
||||
makeRow({
|
||||
videoId: 3,
|
||||
sourcePath: '/media/anime/Other/Other - 05.mkv',
|
||||
parsedTitle: 'Other',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 5,
|
||||
lastWatchedMs: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 2);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
|
||||
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
|
||||
assert.equal(series[1]?.displayName, 'Other');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries filters series roots that no longer exist', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
|
||||
parsedTitle: 'Missing',
|
||||
lastWatchedMs: 5000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries falls back to directory name for display', () => {
|
||||
const rows = [
|
||||
makeRow({
|
||||
sourcePath: '/media/anime/Some Show Dir/video.mkv',
|
||||
parsedTitle: null,
|
||||
animeTitle: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series[0]?.displayName, 'Some Show Dir');
|
||||
});
|
||||
|
||||
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
|
||||
const videos = [
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
];
|
||||
|
||||
assert.deepEqual(sortVideosByEpisode(videos), [
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
function createSeriesTree(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
|
||||
const seriesRoot = path.join(root, 'Show');
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
fs.mkdirSync(season1, { recursive: true });
|
||||
fs.mkdirSync(season2, { recursive: true });
|
||||
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
|
||||
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
|
||||
fs.writeFileSync(path.join(season1, name), '');
|
||||
}
|
||||
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
|
||||
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
|
||||
return seriesRoot;
|
||||
}
|
||||
|
||||
test('listSeasonDirs returns only video-bearing directories in season order', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.name),
|
||||
['Season-1', 'Season-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.season),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances within a season and across seasons', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
|
||||
path.join(season1, 'Show - S01E03.mkv'),
|
||||
);
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
|
||||
path.join(season2, 'Show - S02E01.mkv'),
|
||||
);
|
||||
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode falls back to episode numbers when file was removed', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
|
||||
function createHistoryDb(
|
||||
dbPath: string,
|
||||
options: { wal?: boolean; coverArt?: boolean } = {},
|
||||
): void {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
||||
db.run(`
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
canonical_title TEXT,
|
||||
title_romaji TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anime_id INTEGER,
|
||||
source_type INTEGER,
|
||||
source_path TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
video_id INTEGER,
|
||||
started_at_ms TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
|
||||
db.run(`
|
||||
INSERT INTO imm_videos VALUES
|
||||
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
|
||||
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
|
||||
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
|
||||
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
|
||||
`);
|
||||
db.run(`
|
||||
INSERT INTO imm_sessions VALUES
|
||||
(1, 1, '1000'),
|
||||
(2, 1, '5000'),
|
||||
(3, 2, '3000'),
|
||||
(4, 3, '9000');
|
||||
`);
|
||||
if (options.coverArt) {
|
||||
db.run(`
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
cover_blob_hash TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL
|
||||
);
|
||||
`);
|
||||
// Art only on video 1; video 2 resolves it through the shared anime_id.
|
||||
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
|
||||
}
|
||||
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertHistoryRows(dbPath: string): void {
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.lastWatchedMs, 5000);
|
||||
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.lastWatchedMs, 3000);
|
||||
}
|
||||
|
||||
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath);
|
||||
assertHistoryRows(dbPath);
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.coverBlobHash, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
|
||||
const covers = materializeCoverArt(
|
||||
dbPath,
|
||||
['hash-1', 'hash-1', null, 'hash-missing'],
|
||||
cacheDir,
|
||||
);
|
||||
const coverPath = covers.get('hash-1');
|
||||
assert.ok(coverPath);
|
||||
assert.equal(path.extname(coverPath!), '.png');
|
||||
assert.ok(fs.statSync(coverPath!).size > 0);
|
||||
assert.equal(covers.has('hash-missing'), false);
|
||||
|
||||
// Cached file is reused even when the database has disappeared.
|
||||
fs.rmSync(dbPath);
|
||||
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
|
||||
assert.equal(cachedCovers.get('hash-1'), coverPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
const unsafeHash = '../escape';
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
|
||||
|
||||
assert.equal(covers.has(unsafeHash), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectImageExtension identifies common cover formats', () => {
|
||||
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
|
||||
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
|
||||
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
|
||||
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
|
||||
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.lastWatched.videoId, 2);
|
||||
assert.equal(series[0]?.coverBlobHash, 'hash-1');
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { wal: true });
|
||||
// Reproduce the state after the app shuts down cleanly: WAL journal mode
|
||||
// with no -wal/-shm sidecar files on disk. A read-only connection then
|
||||
// fails at query time because it cannot recreate them.
|
||||
fs.rmSync(`${dbPath}-wal`, { force: true });
|
||||
fs.rmSync(`${dbPath}-shm`, { force: true });
|
||||
assertHistoryRows(dbPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
|
||||
const walDbPath = path.join(dir, 'wal.sqlite');
|
||||
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
|
||||
try {
|
||||
createHistoryDb(walDbPath, { wal: true });
|
||||
createHistoryDb(rollbackDbPath);
|
||||
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('unable to open database file'), {
|
||||
code: 'SQLITE_CANTOPEN',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
rollbackDbPath,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './history-art.js';
|
||||
export * from './history-db.js';
|
||||
export * from './history-navigation.js';
|
||||
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
@@ -29,6 +29,7 @@ function createArgs(): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runLogsCommand } from './commands/logs-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runHistoryCommand } from './commands/history-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
@@ -142,6 +143,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appContext.args.history) {
|
||||
const selected = await runHistoryCommand(appContext);
|
||||
if (!selected) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = selected;
|
||||
appContext.args.targetKind = 'file';
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
|
||||
assert.equal(parsed.settings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root watch history flags', () => {
|
||||
const shortParsed = parseArgs(['-H'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--history'], 'subminer', {});
|
||||
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
|
||||
const defaultParsed = parseArgs([], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.history, true);
|
||||
assert.equal(longParsed.history, true);
|
||||
assert.equal(rofiParsed.history, true);
|
||||
assert.equal(rofiParsed.useRofi, true);
|
||||
assert.equal(defaultParsed.history, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface Args {
|
||||
texthookerOnly: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
useRofi: boolean;
|
||||
history: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
passwordStore: string;
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const electron_1 = require("electron");
|
||||
const help_1 = require("./cli/help");
|
||||
const main_entry_runtime_1 = require("./main-entry-runtime");
|
||||
const early_single_instance_1 = require("./main/early-single-instance");
|
||||
const main_entry_launch_config_1 = require("./main-entry-launch-config");
|
||||
const app_control_client_1 = require("./shared/app-control-client");
|
||||
const first_run_setup_plugin_1 = require("./main/runtime/first-run-setup-plugin");
|
||||
const windows_mpv_launch_1 = require("./main/runtime/windows-mpv-launch");
|
||||
const stats_daemon_entry_1 = require("./stats-daemon-entry");
|
||||
const fatal_error_1 = require("./main/fatal-error");
|
||||
const mpv_logging_args_1 = require("./shared/mpv-logging-args");
|
||||
const log_files_1 = require("./shared/log-files");
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
function appendWindowsMpvLaunchLog(message, logRotation) {
|
||||
if (!(0, log_files_1.isLogFileEnabled)('app')) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
(0, log_files_1.appendLogLine)(process.env.SUBMINER_APP_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('app'), `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, { rotation: logRotation });
|
||||
}
|
||||
function applySanitizedEnv(sanitizedEnv) {
|
||||
if (sanitizedEnv.NODE_NO_WARNINGS) {
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
}
|
||||
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
else {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
}
|
||||
function resolveBundledWindowsMpvPluginEntrypoint() {
|
||||
return ((0, first_run_setup_plugin_1.resolvePackagedRuntimePluginPath)({
|
||||
dirname: __dirname,
|
||||
appPath: electron_1.app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}) ?? undefined);
|
||||
}
|
||||
function buildInstalledWindowsMpvPluginMessage(pathValue, version) {
|
||||
return [
|
||||
'SubMiner detected an installed mpv plugin at:',
|
||||
pathValue,
|
||||
'',
|
||||
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
|
||||
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
|
||||
].join('\n');
|
||||
}
|
||||
async function promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection) {
|
||||
const response = await electron_1.dialog.showMessageBox({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path ?? 'unknown path', detection.version),
|
||||
detail: 'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
|
||||
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
});
|
||||
if (response.response === 2) {
|
||||
return 'cancel';
|
||||
}
|
||||
if (response.response === 1) {
|
||||
return 'continue';
|
||||
}
|
||||
const candidates = (0, first_run_setup_plugin_1.detectInstalledFirstRunPluginCandidates)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
});
|
||||
const result = await (0, first_run_setup_plugin_1.removeLegacyMpvPluginCandidates)({
|
||||
candidates,
|
||||
trashItem: (candidatePath) => electron_1.shell.trashItem(candidatePath),
|
||||
});
|
||||
if (result.ok) {
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Legacy mpv plugin removed',
|
||||
message: 'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||
});
|
||||
return 'removed';
|
||||
}
|
||||
await electron_1.dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Could not remove legacy mpv plugin',
|
||||
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||
});
|
||||
return 'cancel';
|
||||
}
|
||||
function createWindowsRuntimePluginPolicy() {
|
||||
return {
|
||||
detectInstalledMpvPlugin: (mpvPath) => (0, first_run_setup_plugin_1.detectInstalledMpvPlugin)({
|
||||
platform: 'win32',
|
||||
homeDir: node_os_1.default.homedir(),
|
||||
appDataDir: electron_1.app.getPath('appData'),
|
||||
mpvExecutablePath: mpvPath,
|
||||
}),
|
||||
notifyInstalledPluginDetected: (detection) => {
|
||||
if (!detection.installed || !detection.path)
|
||||
return;
|
||||
electron_1.dialog.showMessageBoxSync({
|
||||
type: 'warning',
|
||||
title: 'SubMiner mpv plugin detected',
|
||||
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
|
||||
});
|
||||
},
|
||||
resolveInstalledPluginBeforeLaunch: (detection, mpvPath) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
|
||||
};
|
||||
}
|
||||
process.argv = (0, main_entry_runtime_1.normalizeStartupArgv)(process.argv, process.env);
|
||||
(0, main_entry_runtime_1.applyEarlyLinuxCommandLineSwitches)(electron_1.app.commandLine, process.argv);
|
||||
applySanitizedEnv((0, main_entry_runtime_1.sanitizeStartupEnv)(process.env));
|
||||
const userDataPath = (0, main_entry_runtime_1.configureEarlyAppPaths)(electron_1.app);
|
||||
const reportFatalError = (0, fatal_error_1.createFatalErrorReporter)({
|
||||
showErrorBox: (title, details) => electron_1.dialog.showErrorBox(title, details),
|
||||
consoleError: (message, error) => console.error(message, error),
|
||||
});
|
||||
(0, fatal_error_1.registerFatalErrorHandlers)({
|
||||
reportFatalError,
|
||||
exit: (code) => electron_1.app.exit(code),
|
||||
});
|
||||
function startMainProcess() {
|
||||
const gotSingleInstanceLock = (0, early_single_instance_1.requestSingleInstanceLockEarly)(electron_1.app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
electron_1.app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
}
|
||||
catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
electron_1.app.exit(1);
|
||||
}
|
||||
}
|
||||
async function forwardStartupArgvViaAppControlIfAvailable() {
|
||||
if (!(0, main_entry_runtime_1.shouldForwardStartupArgvViaAppControl)(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
const result = await (0, app_control_client_1.sendAppControlCommand)(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
electron_1.app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
electron_1.app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function runEntryProcess() {
|
||||
if ((0, main_entry_runtime_1.shouldHandleHelpOnlyAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeHelpEnv)(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
|
||||
delete process.env.VK_INSTANCE_LAYERS;
|
||||
}
|
||||
(0, help_1.printHelp)(DEFAULT_TEXTHOOKER_PORT);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleLaunchMpvAtEntry)(process.argv, process.env)) {
|
||||
const sanitizedEnv = (0, main_entry_runtime_1.sanitizeLaunchMpvEnv)(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
await electron_1.app.whenReady();
|
||||
const configuredMpvLaunch = (0, main_entry_launch_config_1.readConfiguredWindowsMpvLaunch)(userDataPath);
|
||||
const extraArgs = (0, main_entry_runtime_1.normalizeLaunchMpvExtraArgs)(process.argv);
|
||||
(0, log_files_1.applyLogFileTogglesToEnv)(configuredMpvLaunch.logFiles);
|
||||
const mpvLogPath = (0, log_files_1.isLogFileEnabled)('mpv')
|
||||
? process.env.SUBMINER_MPV_LOG?.trim() || (0, log_files_1.resolveDefaultLogFilePath)('mpv')
|
||||
: '';
|
||||
if (mpvLogPath) {
|
||||
(0, log_files_1.pruneLogDirectoryForPath)(mpvLogPath, configuredMpvLaunch.logRotation);
|
||||
}
|
||||
const result = await (0, windows_mpv_launch_1.launchWindowsMpv)((0, main_entry_runtime_1.normalizeLaunchMpvTargets)(process.argv), (0, windows_mpv_launch_1.createWindowsMpvLaunchDeps)({
|
||||
getEnv: (name) => process.env[name],
|
||||
isAppControlServerAvailable: () => (0, app_control_client_1.isAppControlServerAvailable)({
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 350,
|
||||
}),
|
||||
sendAppControlCommand: (argv) => (0, app_control_client_1.sendAppControlCommand)(argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
showError: (title, content) => {
|
||||
electron_1.dialog.showErrorBox(title, content);
|
||||
},
|
||||
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
|
||||
}), [...extraArgs, ...(0, mpv_logging_args_1.buildMpvLoggingArgs)(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, configuredMpvLaunch.launchMode, createWindowsRuntimePluginPolicy(), configuredMpvLaunch.pluginRuntimeConfig);
|
||||
electron_1.app.exit(result.ok ? 0 : 1);
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldHandleStatsDaemonCommandAtEntry)(process.argv, process.env)) {
|
||||
await electron_1.app.whenReady();
|
||||
const exitCode = await (0, stats_daemon_entry_1.runStatsDaemonControlFromProcess)(electron_1.app.getPath('userData'));
|
||||
electron_1.app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
if (await forwardStartupArgvViaAppControlIfAvailable()) {
|
||||
return;
|
||||
}
|
||||
if ((0, main_entry_runtime_1.shouldDetachBackgroundLaunch)(process.argv, process.env)) {
|
||||
const childArgs = (0, main_entry_runtime_1.hasTransportedStartupArgs)(process.env) ? [] : process.argv.slice(1);
|
||||
const child = (0, node_child_process_1.spawn)(process.execPath, childArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: (0, main_entry_runtime_1.sanitizeBackgroundEnv)(process.env),
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
startMainProcess();
|
||||
}
|
||||
void runEntryProcess().catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
//# sourceMappingURL=main-entry.js.map
|
||||
@@ -108,15 +108,11 @@ function renderPrereleaseBaseVersionMarker(version: string): string {
|
||||
}
|
||||
|
||||
function extractPrereleaseBaseVersionMarker(notes: string): string | null {
|
||||
return (
|
||||
/<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null
|
||||
);
|
||||
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function stripPrereleaseMetadata(notes: string): string {
|
||||
return notes
|
||||
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
|
||||
.trim();
|
||||
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
|
||||
|
||||
@@ -768,13 +768,110 @@ test('AnkiIntegration reports partial queued YouTube media updates separately fr
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
||||
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
||||
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const storedMedia: string[] = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
},
|
||||
media: {
|
||||
imageFormat: 'jpg',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
currentVideoPath: 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentTimePos: 11,
|
||||
} as never,
|
||||
() => undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => null,
|
||||
() => true,
|
||||
() => 'https://www.youtube.com/watch?v=abc123',
|
||||
);
|
||||
|
||||
const internals = integration as unknown as {
|
||||
client: {
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown[]>;
|
||||
updateNoteFields: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
||||
storeMediaFile: (filename: string) => Promise<void>;
|
||||
};
|
||||
mediaGenerator: {
|
||||
generateAudio: () => Promise<Buffer>;
|
||||
generateScreenshot: () => Promise<Buffer>;
|
||||
};
|
||||
queuePendingYoutubeMediaUpdateForNote: (job: {
|
||||
noteId: number;
|
||||
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
|
||||
label: string | number;
|
||||
}) => Promise<boolean>;
|
||||
showNotification: () => Promise<void>;
|
||||
};
|
||||
internals.client = {
|
||||
notesInfo: async (noteIds) =>
|
||||
noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
})),
|
||||
updateNoteFields: async (noteId, fields) => {
|
||||
updatedNotes.push({ noteId, fields });
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async () => Buffer.from('audio'),
|
||||
generateScreenshot: async () => Buffer.from('image'),
|
||||
};
|
||||
internals.showNotification = async () => undefined;
|
||||
|
||||
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
|
||||
noteId: 404,
|
||||
noteInfo: {
|
||||
noteId: 404,
|
||||
fields: {
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
},
|
||||
},
|
||||
label: 'resolved source',
|
||||
});
|
||||
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
||||
|
||||
assert.equal(queued, true);
|
||||
assert.equal(updatedNotes.length, 1);
|
||||
assert.equal(updatedNotes[0]?.noteId, 404);
|
||||
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
||||
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
});
|
||||
|
||||
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||
const audioCalls: Array<{
|
||||
path: string;
|
||||
audioStreamIndex?: number;
|
||||
normalizeAudio?: boolean;
|
||||
}> = [];
|
||||
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
media: {
|
||||
audioPadding: 0,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
@@ -804,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
) => Promise<Buffer>;
|
||||
};
|
||||
generateAudio: () => Promise<Buffer | null>;
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex });
|
||||
generateAudio: async (
|
||||
path,
|
||||
_startTime,
|
||||
_endTime,
|
||||
_audioPadding,
|
||||
audioStreamIndex,
|
||||
normalizeAudio,
|
||||
) => {
|
||||
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||
return Buffer.from('audio');
|
||||
},
|
||||
};
|
||||
@@ -821,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
||||
{
|
||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||
audioStreamIndex: undefined,
|
||||
normalizeAudio: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -239,6 +239,9 @@ export class AnkiIntegration {
|
||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||
null;
|
||||
private shouldRequireRemoteMediaCache: (() => boolean) | null = null;
|
||||
private getYoutubeMediaSourceUrl:
|
||||
| (() => Promise<string | null | undefined> | string | null | undefined)
|
||||
| null = null;
|
||||
private pendingYoutubeMediaQueue: PendingYoutubeMediaQueue;
|
||||
|
||||
constructor(
|
||||
@@ -257,6 +260,7 @@ export class AnkiIntegration {
|
||||
overlayNotificationCallback?: (payload: OverlayNotificationPayload) => void,
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
|
||||
shouldRequireRemoteMediaCache?: () => boolean,
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -271,6 +275,7 @@ export class AnkiIntegration {
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.getCachedMediaPath = getCachedMediaPath ?? null;
|
||||
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
|
||||
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
|
||||
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
@@ -343,6 +348,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -356,7 +362,7 @@ export class AnkiIntegration {
|
||||
),
|
||||
},
|
||||
getConfig: () => this.config,
|
||||
getCurrentVideoPath: () => this.mpvClient.currentVideoPath,
|
||||
getCurrentVideoPath: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
getCachedMediaPath: this.getCachedMediaPath,
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getSubtitleMediaRange: (context) => this.getSubtitleMediaRange(context),
|
||||
@@ -474,6 +480,7 @@ export class AnkiIntegration {
|
||||
getMpvClient: () => this.mpvClient,
|
||||
...(this.getCachedMediaPath ? { getCachedMediaPath: this.getCachedMediaPath } : {}),
|
||||
shouldRequireRemoteMediaCache: () => this.shouldRequireRemoteMediaCache?.() === true,
|
||||
getYoutubeMediaSourceUrl: () => this.getCurrentYoutubeMediaSourceUrl(),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdate(job),
|
||||
getDeck: () => this.config.deck,
|
||||
client: {
|
||||
@@ -496,6 +503,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
audioPadding,
|
||||
audioStreamIndex,
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
),
|
||||
generateScreenshot: (videoPath, timestamp, options) =>
|
||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||
@@ -695,8 +703,8 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text);
|
||||
isKnownWord(text: string, reading?: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text, reading);
|
||||
}
|
||||
|
||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||
@@ -945,6 +953,14 @@ export class AnkiIntegration {
|
||||
return this.pendingYoutubeMediaQueue.queueFromNote(job);
|
||||
}
|
||||
|
||||
private async getCurrentYoutubeMediaSourceUrl(): Promise<string> {
|
||||
return (
|
||||
trimToNonEmptyString(await this.getYoutubeMediaSourceUrl?.()) ??
|
||||
trimToNonEmptyString(this.mpvClient.currentVideoPath) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
async handleYoutubeMediaCacheReady(
|
||||
sourceUrl: string,
|
||||
cachedPath: string,
|
||||
@@ -982,6 +998,7 @@ export class AnkiIntegration {
|
||||
endTime,
|
||||
this.config.media?.audioPadding,
|
||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||
this.config.media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
||||
|
||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
assert.equal(storedMedia.length, 2);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const imagePaths: string[] = [];
|
||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
assert.deepEqual(audioPaths, [audioUrl]);
|
||||
assert.deepEqual(imagePaths, [videoUrl]);
|
||||
});
|
||||
|
||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||
|
||||
@@ -28,6 +28,14 @@ function shouldGenerateImage(config: AnkiConnectConfig): boolean {
|
||||
return config.media?.generateImage !== false;
|
||||
}
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export interface CardCreationNoteInfo {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
@@ -57,6 +65,7 @@ interface CardCreationMediaGenerator {
|
||||
endTime: number,
|
||||
audioPadding?: number,
|
||||
audioStreamIndex?: number,
|
||||
normalizeAudio?: boolean,
|
||||
): Promise<Buffer | null>;
|
||||
generateScreenshot(
|
||||
path: MediaInput,
|
||||
@@ -90,6 +99,7 @@ interface CardCreationDeps {
|
||||
getMpvClient: () => MpvClient;
|
||||
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'];
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
queuePendingYoutubeMediaUpdate?: (job: PendingYoutubeMediaUpdate) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
client: CardCreationClient;
|
||||
@@ -705,7 +715,9 @@ export class CardCreationService {
|
||||
const label = sentence.length > 30 ? sentence.substring(0, 30) + '...' : sentence;
|
||||
if (shouldQueuePendingYoutubeMedia) {
|
||||
this.deps.queuePendingYoutubeMediaUpdate?.({
|
||||
sourceUrl: mpvClient.currentVideoPath,
|
||||
sourceUrl:
|
||||
trimToNonEmptyString(await this.deps.getYoutubeMediaSourceUrl?.()) ??
|
||||
mpvClient.currentVideoPath,
|
||||
noteId,
|
||||
startTime,
|
||||
endTime,
|
||||
@@ -831,6 +843,7 @@ export class CardCreationService {
|
||||
videoPath,
|
||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||
),
|
||||
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
|
||||
const deleted: number[][] = [];
|
||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
const osdMessages: string[] = [];
|
||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||
const mergeCalls: Array<{
|
||||
keepNoteId: number;
|
||||
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
|
||||
statuses.push(message);
|
||||
},
|
||||
showNotification: async () => undefined,
|
||||
showOsdNotification: () => undefined,
|
||||
showOsdNotification: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
logError: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
truncateSentence: (value: string) => value,
|
||||
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
|
||||
addedTags,
|
||||
rememberedMerges,
|
||||
statuses,
|
||||
osdMessages,
|
||||
mergeCalls,
|
||||
setManualChoice: (choice: typeof manualChoice) => {
|
||||
manualChoice = choice;
|
||||
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
});
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
|
||||
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
|
||||
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
||||
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||
// handleManual owns all user-facing notifications; callers must not re-notify on false.
|
||||
this.deps.showOsdNotification('Field grouping failed: original card not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
||||
assert.deepEqual(harness.manualCalls, []);
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
|
||||
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
|
||||
const harness = createHarness({
|
||||
kikuFieldGrouping: 'manual',
|
||||
noteIds: [9],
|
||||
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
|
||||
expression: 'word-9',
|
||||
},
|
||||
]);
|
||||
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
|
||||
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
|
||||
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
|
||||
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
|
||||
});
|
||||
|
||||
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
||||
|
||||
@@ -156,15 +156,14 @@ export class FieldGroupingService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handled = await this.deps.handleFieldGroupingManual(
|
||||
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
|
||||
// unavailable, failed) — re-notifying on a false return here duplicated them.
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
if (!handled) {
|
||||
this.deps.showOsdNotification('Field grouping cancelled');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error triggering field grouping:', (error as Error).message);
|
||||
|
||||
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
// v2 states carry no readings, so they load usable but stale to trigger a
|
||||
// prompt upgrade refresh.
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
getMsUntilNextRefresh: () => number;
|
||||
}
|
||||
).getMsUntilNextRefresh(),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||
notes: {
|
||||
'1': [{ word: '猫', reading: 'ねこ' }],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
words: string[];
|
||||
notes?: Record<string, string[]>;
|
||||
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
||||
assert.equal(persisted.version, 3);
|
||||
assert.deepEqual(persisted.notes, {
|
||||
'1': ['鳥'],
|
||||
'1': [{ word: '鳥', reading: null }],
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
scope: string;
|
||||
words: string[];
|
||||
notes: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||
assert.deepEqual(persisted.words, ['猫']);
|
||||
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -648,3 +695,200 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
|
||||
// Same spelling, different word (床/とこ "bed") must not match.
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
// Note readings stay matchable as kana words.
|
||||
assert.equal(manager.isKnownWord('ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
decks: {
|
||||
'Kaishi 1.5k': ['Word'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager matches words without readings in any reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: 'お 決[き]まり' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('お決まり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: '漢字' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('漢字'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: 'たべる' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('たべる'), true);
|
||||
assert.equal(manager.isKnownWord('タベル'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||
KnownWordEntry,
|
||||
convertKatakanaToHiragana,
|
||||
isReadingFieldName,
|
||||
knownWordEntryListsEqual,
|
||||
normalizeKnownReadingForLookup,
|
||||
normalizeKnownWordEntryList,
|
||||
parseFuriganaAnnotatedText,
|
||||
} from './known-word-entries';
|
||||
|
||||
const log = createLogger('anki').child('integration.known-word-cache');
|
||||
|
||||
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
|
||||
readonly notes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
||||
interface KnownWordCacheStateV3 {
|
||||
readonly version: 3;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly notes: Record<string, KnownWordEntry[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||
|
||||
const NO_READING_KEY = '';
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
|
||||
export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private wordReferenceCounts = new Map<string, number>();
|
||||
private noteWordsById = new Map<number, string[]>();
|
||||
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||
// entries fail open: the word matches regardless of the token's reading.
|
||||
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||
private readingCounts = new Map<string, number>();
|
||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
@@ -120,13 +142,28 @@ export class KnownWordCacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
isKnownWord(text: string, reading?: string): boolean {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(text);
|
||||
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||
if (knownReadings && knownReadings.size > 0) {
|
||||
const normalizedReading =
|
||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||
return (
|
||||
normalizedReading.length === 0 ||
|
||||
knownReadings.has(NO_READING_KEY) ||
|
||||
knownReadings.has(normalizedReading)
|
||||
);
|
||||
}
|
||||
|
||||
return this.readingCounts.has(convertKatakanaToHiragana(normalized));
|
||||
}
|
||||
|
||||
refresh(force = false): Promise<void> {
|
||||
@@ -173,7 +210,7 @@ export class KnownWordCacheManager {
|
||||
let didMutateCache = false;
|
||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
|
||||
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsStateKey) {
|
||||
@@ -185,8 +222,8 @@ export class KnownWordCacheManager {
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
|
||||
if (!changed) {
|
||||
return didMutateCache;
|
||||
}
|
||||
@@ -198,7 +235,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`noteId=${noteInfo.noteId}`,
|
||||
`wordCount=${nextWords.length}`,
|
||||
`wordCount=${nextEntries.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
return true;
|
||||
@@ -236,11 +273,11 @@ export class KnownWordCacheManager {
|
||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||
|
||||
if (this.noteWordsById.size === 0) {
|
||||
if (this.noteEntriesById.size === 0) {
|
||||
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
||||
} else {
|
||||
const currentNoteIdSet = new Set(currentNoteIds);
|
||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||
for (const noteId of Array.from(this.noteEntriesById.keys())) {
|
||||
if (!currentNoteIdSet.has(noteId)) {
|
||||
this.removeNoteSnapshot(noteId);
|
||||
}
|
||||
@@ -251,7 +288,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(
|
||||
this.extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo,
|
||||
noteFieldsById.get(noteInfo.noteId),
|
||||
),
|
||||
@@ -266,7 +303,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.knownWords.size}`,
|
||||
`wordCount=${this.wordReadingCounts.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -291,7 +328,13 @@ export class KnownWordCacheManager {
|
||||
|
||||
private getDefaultKnownWordFields(): string[] {
|
||||
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
|
||||
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
|
||||
return this.withDefaultReadingFields([configuredWordField, 'Word']);
|
||||
}
|
||||
|
||||
// Reading fields are always probed (even when a deck configures explicit
|
||||
// word fields) so entries can carry the reading their note teaches.
|
||||
private withDefaultReadingFields(fields: string[]): string[] {
|
||||
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
|
||||
}
|
||||
|
||||
private getKnownWordDecks(): string[] {
|
||||
@@ -337,7 +380,9 @@ export class KnownWordCacheManager {
|
||||
.filter((field) => field.length > 0),
|
||||
),
|
||||
];
|
||||
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
|
||||
return normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields();
|
||||
}
|
||||
|
||||
const deckFields = selectedDeckEntry[1];
|
||||
@@ -351,7 +396,7 @@ export class KnownWordCacheManager {
|
||||
),
|
||||
];
|
||||
if (normalizedFields.length > 0) {
|
||||
return normalizedFields;
|
||||
return this.withDefaultReadingFields(normalizedFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +427,10 @@ export class KnownWordCacheManager {
|
||||
: [];
|
||||
scopes.push({
|
||||
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
|
||||
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
|
||||
fields:
|
||||
normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields(),
|
||||
});
|
||||
}
|
||||
if (scopes.length > 0) {
|
||||
@@ -490,7 +538,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -519,56 +567,77 @@ export class KnownWordCacheManager {
|
||||
return noteInfos;
|
||||
}
|
||||
|
||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
||||
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
|
||||
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
|
||||
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
if (normalizedWords.length > 0) {
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
if (normalizedEntries.length > 0) {
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
} else {
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeNoteSnapshot(noteId: number): void {
|
||||
const previousWords = this.noteWordsById.get(noteId);
|
||||
if (!previousWords) {
|
||||
const previousEntries = this.noteEntriesById.get(noteId);
|
||||
if (!previousEntries) {
|
||||
return;
|
||||
}
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
}
|
||||
|
||||
private addWordsToCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
this.knownWords.add(word);
|
||||
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
let readings = this.wordReadingCounts.get(entry.word);
|
||||
if (!readings) {
|
||||
readings = new Map();
|
||||
this.wordReadingCounts.set(entry.word, readings);
|
||||
}
|
||||
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||
if (entry.reading) {
|
||||
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeWordsFromCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
} else {
|
||||
this.wordReferenceCounts.delete(word);
|
||||
this.knownWords.delete(word);
|
||||
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
const readings = this.wordReadingCounts.get(entry.word);
|
||||
if (readings) {
|
||||
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
readings.set(readingKey, nextCount);
|
||||
} else {
|
||||
readings.delete(readingKey);
|
||||
if (readings.size === 0) {
|
||||
this.wordReadingCounts.delete(entry.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.reading) {
|
||||
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||
if (nextReadingCount > 0) {
|
||||
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||
} else {
|
||||
this.readingCounts.delete(entry.reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.wordReferenceCounts = new Map();
|
||||
this.noteWordsById = new Map();
|
||||
this.wordReadingCounts = new Map();
|
||||
this.readingCounts = new Map();
|
||||
this.noteEntriesById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
@@ -601,32 +670,48 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 3) {
|
||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version === 2) {
|
||||
// Older states have no readings; load them reading-less (fail-open,
|
||||
// matching the old behavior) but leave the cache marked stale so the
|
||||
// next refresh upgrades entries with readings from Anki.
|
||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedWords = normalizeKnownWordList(words);
|
||||
if (normalizedWords.length === 0) {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(
|
||||
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||
);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
}
|
||||
} else {
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
this.wordReferenceCounts.set(normalized, 1);
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
} catch (error) {
|
||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||
this.clearInMemoryState();
|
||||
@@ -636,18 +721,17 @@ export class KnownWordCacheManager {
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const notes: Record<string, string[]> = {};
|
||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||
if (words.length > 0) {
|
||||
notes[String(noteId)] = words;
|
||||
const notes: Record<string, KnownWordEntry[]> = {};
|
||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||
if (entries.length > 0) {
|
||||
notes[String(noteId)] = entries;
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV2 = {
|
||||
version: 2,
|
||||
const state: KnownWordCacheStateV3 = {
|
||||
version: 3,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
words: Array.from(this.knownWords),
|
||||
notes,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
@@ -659,14 +743,18 @@ export class KnownWordCacheManager {
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (candidate.version !== 3) {
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (candidate.version === 2 || candidate.version === 3) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
@@ -674,10 +762,18 @@ export class KnownWordCacheManager {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isValidNoteEntry =
|
||||
candidate.version === 2
|
||||
? (entry: unknown): boolean => typeof entry === 'string'
|
||||
: (entry: unknown): boolean =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||
((entry as KnownWordEntry).reading === null ||
|
||||
typeof (entry as KnownWordEntry).reading === 'string');
|
||||
if (
|
||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
||||
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -686,11 +782,12 @@ export class KnownWordCacheManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractNormalizedKnownWordsFromNoteInfo(
|
||||
private extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo: KnownWordCacheNoteInfo,
|
||||
preferredFields = this.getConfiguredFields(),
|
||||
): string[] {
|
||||
const words: string[] = [];
|
||||
): KnownWordEntry[] {
|
||||
const wordValues: string[] = [];
|
||||
let noteReading: string | null = null;
|
||||
for (const preferredField of preferredFields) {
|
||||
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
||||
if (!fieldName) continue;
|
||||
@@ -698,12 +795,36 @@ export class KnownWordCacheManager {
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||
if (normalized) {
|
||||
words.push(normalized);
|
||||
const cleaned = this.normalizeRawKnownWordValue(raw);
|
||||
if (!cleaned) continue;
|
||||
|
||||
if (isReadingFieldName(preferredField)) {
|
||||
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
|
||||
if (normalizedReading) {
|
||||
noteReading ??= normalizedReading;
|
||||
continue;
|
||||
}
|
||||
// Non-kana content in a reading field: treat it as a word so decks
|
||||
// with repurposed reading fields keep matching (fail-open).
|
||||
}
|
||||
wordValues.push(cleaned);
|
||||
}
|
||||
return normalizeKnownWordList(words);
|
||||
|
||||
const entries: KnownWordEntry[] = [];
|
||||
for (const value of wordValues) {
|
||||
const parsed = parseFuriganaAnnotatedText(value);
|
||||
const word = parsed.text.trim().toLowerCase();
|
||||
if (!word) continue;
|
||||
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
|
||||
entries.push({ word, reading: inlineReading || noteReading });
|
||||
}
|
||||
|
||||
// Kana-only notes (reading field but no word field) stay matchable.
|
||||
if (entries.length === 0 && noteReading) {
|
||||
entries.push({ word: noteReading, reading: noteReading });
|
||||
}
|
||||
|
||||
return normalizeKnownWordEntryList(entries);
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
@@ -718,22 +839,6 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnownWordList(words: string[]): string[] {
|
||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
||||
}
|
||||
|
||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Known-word cache entries pair a word with the reading its Anki note teaches,
|
||||
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
|
||||
// unrelated words as known. reading === null means the note carries no usable
|
||||
// reading and the word matches in any reading (fail-open).
|
||||
export interface KnownWordEntry {
|
||||
word: string;
|
||||
reading: string | null;
|
||||
}
|
||||
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
|
||||
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
|
||||
const WHITESPACE_PATTERN = /[\s ]+/g;
|
||||
|
||||
// Reading-bearing field names probed on every known-word note, in addition to
|
||||
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
|
||||
// "ExpressionReading" note types).
|
||||
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
|
||||
'Reading',
|
||||
'Word Reading',
|
||||
'ExpressionReading',
|
||||
'Expression Reading',
|
||||
];
|
||||
|
||||
export function isReadingFieldName(fieldName: string): boolean {
|
||||
return /reading/i.test(fieldName);
|
||||
}
|
||||
|
||||
export function convertKatakanaToHiragana(text: string): string {
|
||||
let converted = '';
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
|
||||
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
|
||||
continue;
|
||||
}
|
||||
converted += char;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function isHiraganaReadingChar(char: string): boolean {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
|
||||
}
|
||||
|
||||
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
|
||||
// reading. Values without brackets pass through with reading null.
|
||||
export function parseFuriganaAnnotatedText(value: string): {
|
||||
text: string;
|
||||
reading: string | null;
|
||||
} {
|
||||
if (!value.includes('[')) {
|
||||
return { text: value, reading: null };
|
||||
}
|
||||
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
|
||||
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
|
||||
return { text, reading: reading.length > 0 ? reading : null };
|
||||
}
|
||||
|
||||
// Returns the hiragana-normalized reading, or '' when the value is not a
|
||||
// plausible kana reading (callers fall back to text-only matching then).
|
||||
export function normalizeKnownReadingForLookup(value: string): string {
|
||||
const parsed = parseFuriganaAnnotatedText(value.trim());
|
||||
const candidate = (parsed.reading ?? parsed.text).trim();
|
||||
if (!candidate) {
|
||||
return '';
|
||||
}
|
||||
const hiragana = convertKatakanaToHiragana(candidate);
|
||||
for (const char of hiragana) {
|
||||
if (!isHiraganaReadingChar(char)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return hiragana;
|
||||
}
|
||||
|
||||
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
|
||||
return `${entry.word}\u0000${entry.reading ?? ''}`;
|
||||
}
|
||||
|
||||
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
|
||||
const byKey = new Map<string, KnownWordEntry>();
|
||||
for (const entry of entries) {
|
||||
const word = entry.word.trim();
|
||||
if (!word) {
|
||||
continue;
|
||||
}
|
||||
const reading = entry.reading?.trim() || null;
|
||||
const normalized: KnownWordEntry = { word, reading };
|
||||
byKey.set(makeKnownWordEntryKey(normalized), normalized);
|
||||
}
|
||||
return [...byKey.values()].sort((left, right) =>
|
||||
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import * as mediaSource from './media-source';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||
|
||||
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
||||
assert.equal(audioResult, audioUrl);
|
||||
assert.equal(videoResult, videoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const signedVideoUrl =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const edlSource = [
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
const result = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'video',
|
||||
);
|
||||
|
||||
assert.equal(result, signedVideoUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const audioUrl = 'https://audio.example/stream';
|
||||
const videoUrl = 'https://video.example/stream';
|
||||
const edlSource = [
|
||||
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
|
||||
'!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
toMpvEdlValue(videoUrl),
|
||||
].join(';');
|
||||
|
||||
const audioResult = await resolveMediaGenerationInputPath(
|
||||
{
|
||||
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => edlSource,
|
||||
},
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(audioResult, audioUrl);
|
||||
});
|
||||
|
||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
||||
).resolveMediaGenerationInput;
|
||||
assert.equal(typeof resolver, 'function');
|
||||
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const edlSource = [
|
||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
||||
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||
].join(';');
|
||||
|
||||
const result = await resolver!(
|
||||
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
||||
'audio',
|
||||
);
|
||||
|
||||
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
||||
assert.equal(result?.path, audioUrl);
|
||||
assert.equal(result?.singleResolvedStream, true);
|
||||
assert.equal(result?.inputOptions?.reconnect, true);
|
||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||
import type { MpvClient } from '../types/runtime';
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
|
||||
export type MediaGenerationKind = 'audio' | 'video';
|
||||
export type MediaGenerationInputSource =
|
||||
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
|
||||
}
|
||||
|
||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
||||
return [...matches]
|
||||
.map((match) => trimToNonEmptyString(match[1]))
|
||||
return extractFileUrlsFromMpvEdlSource(source)
|
||||
.map((value) => trimToNonEmptyString(value))
|
||||
.filter((value): value is string => value !== null);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function toMpvEdlValue(value: string): string {
|
||||
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
|
||||
const url =
|
||||
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
|
||||
const initUrl = 'https://init.example/init.mp4';
|
||||
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
|
||||
fileUrl,
|
||||
)},length=42`;
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
|
||||
});
|
||||
|
||||
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
|
||||
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||
const source = [
|
||||
'edl://!new_stream',
|
||||
toMpvEdlValue(audioUrl),
|
||||
'!new_stream',
|
||||
`file=${toMpvEdlValue(videoUrl)},length=50`,
|
||||
'!global_tags,title=test',
|
||||
].join(';');
|
||||
|
||||
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
const EDL_URI_PREFIX = 'edl://';
|
||||
|
||||
const BYTE_COMMA = ','.charCodeAt(0);
|
||||
const BYTE_CR = '\r'.charCodeAt(0);
|
||||
const BYTE_EQUALS = '='.charCodeAt(0);
|
||||
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
|
||||
const BYTE_LF = '\n'.charCodeAt(0);
|
||||
const BYTE_PERCENT = '%'.charCodeAt(0);
|
||||
const BYTE_SEMICOLON = ';'.charCodeAt(0);
|
||||
|
||||
function isDigitByte(value: number | undefined): value is number {
|
||||
return value !== undefined && value >= 48 && value <= 57;
|
||||
}
|
||||
|
||||
function isEntrySeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
|
||||
}
|
||||
|
||||
function isParamSeparator(value: number | undefined): boolean {
|
||||
return value === BYTE_COMMA || isEntrySeparator(value);
|
||||
}
|
||||
|
||||
function decodeBytes(buffer: Buffer, start: number, end: number): string {
|
||||
return buffer.subarray(start, end).toString('utf8');
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function toEdlDataBuffer(source: string): Buffer {
|
||||
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
|
||||
return Buffer.from(data, 'utf8');
|
||||
}
|
||||
|
||||
function parseLengthGuardedValue(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { value: string; end: number } | null {
|
||||
if (buffer[position] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = position + 1;
|
||||
if (!isDigitByte(buffer[cursor])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let byteLength = 0;
|
||||
while (true) {
|
||||
const digit = buffer[cursor];
|
||||
if (!isDigitByte(digit)) {
|
||||
break;
|
||||
}
|
||||
byteLength = byteLength * 10 + (digit - 48);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (buffer[cursor] !== BYTE_PERCENT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueStart = cursor + 1;
|
||||
const valueEnd = valueStart + byteLength;
|
||||
if (valueEnd > buffer.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: decodeBytes(buffer, valueStart, valueEnd),
|
||||
end: valueEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function skipEntrySeparators(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function skipEntry(buffer: Buffer, position: number): number {
|
||||
let cursor = position;
|
||||
while (cursor < buffer.length) {
|
||||
const guardedValue = parseLengthGuardedValue(buffer, cursor);
|
||||
if (guardedValue) {
|
||||
cursor = guardedValue.end;
|
||||
continue;
|
||||
}
|
||||
if (isEntrySeparator(buffer[cursor])) {
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
return {
|
||||
value: decodeBytes(buffer, position, cursor),
|
||||
end: cursor,
|
||||
};
|
||||
}
|
||||
|
||||
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
|
||||
}
|
||||
|
||||
function parseOptionalParamName(
|
||||
buffer: Buffer,
|
||||
position: number,
|
||||
): { name: string | null; valueStart: number } {
|
||||
let cursor = position;
|
||||
while (
|
||||
cursor < buffer.length &&
|
||||
!isParamSeparator(buffer[cursor]) &&
|
||||
buffer[cursor] !== BYTE_PERCENT &&
|
||||
buffer[cursor] !== BYTE_EXCLAMATION
|
||||
) {
|
||||
if (buffer[cursor] === BYTE_EQUALS) {
|
||||
return {
|
||||
name: decodeBytes(buffer, position, cursor),
|
||||
valueStart: cursor + 1,
|
||||
};
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return { name: null, valueStart: position };
|
||||
}
|
||||
|
||||
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
|
||||
const urls: string[] = [];
|
||||
let cursor = position;
|
||||
let unnamedParamIndex = 0;
|
||||
|
||||
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
|
||||
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
|
||||
const value = parseParamValue(buffer, valueStart);
|
||||
const lowerName = name?.toLowerCase() ?? null;
|
||||
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
|
||||
|
||||
if (isFileParam && isHttpUrl(value.value)) {
|
||||
urls.push(value.value);
|
||||
}
|
||||
|
||||
if (lowerName === null) {
|
||||
unnamedParamIndex += 1;
|
||||
}
|
||||
|
||||
cursor = value.end;
|
||||
if (buffer[cursor] === BYTE_COMMA) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isEntrySeparator(buffer[cursor])) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return { urls, end: cursor };
|
||||
}
|
||||
|
||||
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
|
||||
const buffer = toEdlDataBuffer(source);
|
||||
const urls: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
cursor = skipEntrySeparators(buffer, cursor);
|
||||
if (cursor >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (buffer[cursor] === BYTE_EXCLAMATION) {
|
||||
cursor = skipEntry(buffer, cursor);
|
||||
continue;
|
||||
}
|
||||
|
||||
const segment = parseSegmentEntry(buffer, cursor);
|
||||
urls.push(...segment.urls);
|
||||
cursor = segment.end;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
export function getNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: string): string | null {
|
||||
export function getNoteFieldValue(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
preferredName: string,
|
||||
): string | null {
|
||||
const resolvedFieldName = Object.keys(noteInfo.fields).find(
|
||||
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface PendingYoutubeMediaQueueDeps {
|
||||
'generateAudio' | 'generateScreenshot' | 'generateAnimatedImage'
|
||||
>;
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
getCurrentVideoPath: () => string | undefined;
|
||||
getCurrentVideoPath: () => Promise<string | undefined> | string | undefined;
|
||||
getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null;
|
||||
shouldRequireRemoteMediaCache: () => boolean;
|
||||
getSubtitleMediaRange: (context?: SubtitleMiningContext) => {
|
||||
@@ -104,7 +104,7 @@ export class PendingYoutubeMediaQueue {
|
||||
context?: SubtitleMiningContext;
|
||||
label: string | number;
|
||||
}): Promise<boolean> {
|
||||
const sourceUrl = trimToNonEmptyString(this.deps.getCurrentVideoPath());
|
||||
const sourceUrl = trimToNonEmptyString(await this.deps.getCurrentVideoPath());
|
||||
const getCachedMediaPath = this.deps.getCachedMediaPath;
|
||||
if (!sourceUrl || this.deps.shouldRequireRemoteMediaCache() !== true || !getCachedMediaPath) {
|
||||
return false;
|
||||
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
|
||||
job.endTime,
|
||||
config.media?.audioPadding,
|
||||
undefined,
|
||||
config.media?.normalizeAudio !== false,
|
||||
);
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
|
||||
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
|
||||
model: '',
|
||||
systemPrompt: '',
|
||||
});
|
||||
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||
|
||||
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
animatedMaxHeight: 0,
|
||||
animatedCrf: 35,
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
normalizeAudio: true,
|
||||
audioPadding: 0,
|
||||
fallbackDuration: 3.0,
|
||||
maxMediaDuration: 30,
|
||||
|
||||
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
'ankiConnect.media.normalizeAudio',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
|
||||
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||
description: 'Generate sentence audio for mined cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.normalizeAudio',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
||||
description: 'Normalize generated sentence audio loudness during media extraction.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.media.generateImage',
|
||||
kind: 'boolean',
|
||||
@@ -311,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
||||
description:
|
||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
|
||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||
|
||||
@@ -729,7 +729,7 @@ describe('stats server API routes', () => {
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
||||
assert.deepEqual(seenArgs, ['90d', 'month', true]);
|
||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||
});
|
||||
@@ -747,7 +747,7 @@ describe('stats server API routes', () => {
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['365d', 'month']);
|
||||
assert.deepEqual(seenArgs, ['365d', 'month', true]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||
@@ -763,7 +763,25 @@ describe('stats server API routes', () => {
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day']);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day', true]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getTrendsDashboard: async (...args: unknown[]) => {
|
||||
seenArgs = args;
|
||||
return TRENDS_DASHBOARD;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day', false]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
@@ -114,6 +115,7 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
options.showOverlayNotification,
|
||||
options.getCachedMediaPath,
|
||||
options.shouldRequireRemoteMediaCache,
|
||||
options.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
integration.start();
|
||||
options.setAnkiIntegration(integration);
|
||||
|
||||
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand ensures background stats server for initial --start --background', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
|
||||
|
||||
assert.equal(ensured.length, 1);
|
||||
});
|
||||
|
||||
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
|
||||
|
||||
assert.equal(ensured.length, 1);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
|
||||
|
||||
assert.equal(ensured.length, 0);
|
||||
});
|
||||
|
||||
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
|
||||
runUpdateCommand: options.app.runUpdateCommand,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
@@ -393,6 +396,10 @@ export function handleCliCommand(
|
||||
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
|
||||
}
|
||||
|
||||
if (args.start && args.background) {
|
||||
deps.ensureBackgroundStatsServer?.();
|
||||
}
|
||||
|
||||
if (args.sessionAction) {
|
||||
dispatchCliSessionAction(
|
||||
args.sessionAction,
|
||||
|
||||
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
restoreOnModalClose: 'kiku',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
// Abandonment also asks the renderer hosting the modal to close its dialog.
|
||||
{
|
||||
channel: 'kiku:field-grouping-cancel',
|
||||
restoreOnModalClose: undefined,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(waitCalls, [
|
||||
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
assert.deepEqual(warnings, [
|
||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
assert.deepEqual(closed, ['kiku']);
|
||||
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
|
||||
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
|
||||
// the duplicate is a harmless no-op.
|
||||
assert.deepEqual(closed, ['kiku', 'kiku']);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
|
||||
// The field grouping modal must run the same prerequisites as every other modal
|
||||
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
|
||||
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
|
||||
const order: string[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
// The modal acknowledges open below, so the callback stays pending and arms its response
|
||||
// timeout; stub the timer so no real 90s handle leaks into the test runner.
|
||||
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
try {
|
||||
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
|
||||
sendToVisibleOverlay: (channel) => {
|
||||
order.push(`send:${channel}`);
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => {
|
||||
order.push('wait');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
|
||||
void runtime.createFieldGroupingCallback()({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Let the async send + modal-open ack chain run.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'prereqs',
|
||||
'windows-ready',
|
||||
'send:kiku:field-grouping-request',
|
||||
'wait',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
|
||||
interface WindowLike {
|
||||
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||
handleOverlayModalClosed?: (modal: T) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
/**
|
||||
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
|
||||
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
|
||||
* them the field grouping modal can open with no sibling overlay window present, and on
|
||||
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
|
||||
* modals do.
|
||||
*/
|
||||
ensureOverlayStartupPrereqs?: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
|
||||
sendToVisibleOverlay?: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
data: KikuFieldGroupingRequestData,
|
||||
): Promise<boolean> => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
const sendOpen = (): boolean =>
|
||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
const sendOpen = (): boolean => {
|
||||
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
|
||||
// and visible overlay window exist before handing off to the dedicated modal window.
|
||||
options.ensureOverlayStartupPrereqs?.();
|
||||
options.ensureOverlayWindowsReadyForVisibilityActions?.();
|
||||
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: kikuModal,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!options.waitForModalOpen) {
|
||||
return sendOpen();
|
||||
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
return opened;
|
||||
};
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
|
||||
// lives in the dedicated modal window this is redundant with the teardown below, but it
|
||||
// also covers the case where the request was routed into the visible overlay.
|
||||
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
|
||||
preferModalWindow: true,
|
||||
});
|
||||
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
|
||||
// dedicated modal window). This is what recovers the frozen overlay when a grouping
|
||||
// request times out or fails to reach a visible modal.
|
||||
options.handleOverlayModalClosed?.(kikuModal);
|
||||
};
|
||||
|
||||
const createFieldGroupingCallback = (): ((
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
sendKikuFieldGroupingRequest,
|
||||
dismissModalUi,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { createFieldGroupingCallback } from './field-grouping';
|
||||
|
||||
function makeRequestData(): KikuFieldGroupingRequestData {
|
||||
return {
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'a',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
|
||||
* sequence-guarded closure, so the value read back is never identity-equal to the
|
||||
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
|
||||
* never matched and leaked the resolver, wedging every later grouping attempt.
|
||||
*/
|
||||
function createWrappedResolverStore() {
|
||||
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
return {
|
||||
getResolver: () => stored,
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
stored = resolver ? (choice) => resolver(choice) : null;
|
||||
},
|
||||
respond: (choice: KikuFieldGroupingChoice) => {
|
||||
stored?.(choice);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let visible = false;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
};
|
||||
store.respond(choice);
|
||||
|
||||
assert.deepEqual(await pending, choice);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not reject the next request after a response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
const firstChoice = await first;
|
||||
assert.equal(firstChoice.cancelled, false);
|
||||
|
||||
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
|
||||
const second = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
|
||||
const secondChoice = await second;
|
||||
assert.equal(secondChoice.cancelled, false);
|
||||
assert.equal(secondChoice.keepNoteId, 2);
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback reports modal dismiss failures', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const errors: unknown[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
await callback(makeRequestData());
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await pending;
|
||||
assert.equal(dismissed, 0);
|
||||
});
|
||||
|
||||
test('field grouping callback rejects a concurrent request while one is pending', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let sends = 0;
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => {
|
||||
sends += 1;
|
||||
return true;
|
||||
},
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.equal(sends, 1);
|
||||
|
||||
const second = await callback(makeRequestData());
|
||||
assert.equal(second.cancelled, true);
|
||||
assert.equal(sends, 1);
|
||||
assert.equal(dismissed, 0);
|
||||
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await first;
|
||||
});
|
||||
@@ -1,21 +1,41 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
|
||||
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
|
||||
|
||||
export function createFieldGroupingCallback(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Tears down the modal UI when the request is abandoned without a renderer response
|
||||
* (send failure or response timeout). Without this the dedicated modal window, its
|
||||
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
|
||||
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
|
||||
*/
|
||||
dismissModalUi?: () => void;
|
||||
responseTimeoutMs?: number;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
const cancelledChoice = (): KikuFieldGroupingChoice => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
try {
|
||||
options.dismissModalUi?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
return new Promise((resolve) => {
|
||||
if (options.getResolver()) {
|
||||
resolve({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
resolve(cancelledChoice());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (options.getResolver() === finish) {
|
||||
options.setResolver(null);
|
||||
}
|
||||
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
|
||||
// resolver, so an identity check against `finish` never matches and would leak
|
||||
// the resolver — blocking every later grouping attempt with an instant cancel.
|
||||
options.setResolver(null);
|
||||
resolve(choice);
|
||||
|
||||
// When abandoned without a renderer response, tear down the modal window/state
|
||||
// that the request path spun up. A normal response already routes through the
|
||||
// renderer's close handler, so only the abandon paths need this.
|
||||
if (abandoned) {
|
||||
dismissModalUi();
|
||||
}
|
||||
|
||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(false);
|
||||
}
|
||||
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
|
||||
(sent) => {
|
||||
if (settled) return;
|
||||
if (!sent) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
}
|
||||
}, 90000);
|
||||
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
|
||||
},
|
||||
() => {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildHyprlandPlacementDispatches,
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
findHyprlandWindowForPlacement,
|
||||
hasHyprlandWindowPlacementBoundsMismatch,
|
||||
shouldAttemptHyprlandWindowPlacement,
|
||||
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
execFileSync: (() => {
|
||||
throw new Error('should not query the compositor when placement is not applicable');
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
|
||||
// The window has not been mapped by the compositor yet, so no client matches. Callers use
|
||||
// this to keep retrying until the modal is actually placed above fullscreen mpv.
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 999,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 456,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Overlay Modal',
|
||||
mapped: true,
|
||||
floating: false,
|
||||
pinned: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (args.join(' ') === '-j status') {
|
||||
return JSON.stringify({ configProvider: 'lua' });
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
|
||||
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement outcome for a single reconcile attempt.
|
||||
* - `applicable`: false when not running under Hyprland (nothing to retry).
|
||||
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
|
||||
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
|
||||
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
|
||||
* invisible.
|
||||
* - `dispatched`: whether any hyprctl dispatch was issued.
|
||||
*/
|
||||
export interface HyprlandPlacementStatus {
|
||||
applicable: boolean;
|
||||
clientFound: boolean;
|
||||
dispatched: boolean;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): boolean {
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pid?: number;
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): HyprlandPlacementStatus {
|
||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||
return false;
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: options.title,
|
||||
});
|
||||
if (!client) {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const configProvider = detectHyprlandConfigProvider(run);
|
||||
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||
}
|
||||
}
|
||||
return dispatches.length > 0;
|
||||
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
|
||||
} catch {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
|
||||
async getTrendsDashboard(
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): Promise<unknown> {
|
||||
return getTrendsDashboard(this.db, range, groupBy);
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
}
|
||||
|
||||
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
||||
|
||||
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
|
||||
canonicalTitle: '30d Zero Fill',
|
||||
sourcePath: '/tmp/30d-zerofill.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const createdAtMs = '1772395200000';
|
||||
// Local "today" for the mocked clock is epoch day 20513. Seed only two
|
||||
// active days inside the 30-day window, leaving the rest empty.
|
||||
const todayEpochDay = 20513;
|
||||
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
insertDailyRollup.run(
|
||||
todayEpochDay - 10,
|
||||
videoId,
|
||||
1,
|
||||
45,
|
||||
4,
|
||||
120,
|
||||
3,
|
||||
createdAtMs,
|
||||
createdAtMs,
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'day');
|
||||
|
||||
// Exactly 30 calendar days, not just the two active ones.
|
||||
assert.equal(dashboard.activity.watchTime.length, 30);
|
||||
// Most recent day carries its seeded value; a gap day reads zero.
|
||||
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
|
||||
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
|
||||
// Only the two seeded days contribute to the totals.
|
||||
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
|
||||
assert.equal(nonZeroDays.length, 2);
|
||||
// Cumulative watch time still tops out at the sum of both active days.
|
||||
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
|
||||
// Every day-bucketed series shares the same 30-day axis.
|
||||
assert.equal(dashboard.progress.episodes.length, 30);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
|
||||
canonicalTitle: 'No Zero Fill',
|
||||
sourcePath: '/tmp/no-zerofill.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const createdAtMs = '1772395200000';
|
||||
const todayEpochDay = 20513;
|
||||
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
insertDailyRollup.run(
|
||||
todayEpochDay - 10,
|
||||
videoId,
|
||||
1,
|
||||
45,
|
||||
4,
|
||||
120,
|
||||
3,
|
||||
createdAtMs,
|
||||
createdAtMs,
|
||||
);
|
||||
|
||||
const filled = getTrendsDashboard(db, '30d', 'day', true);
|
||||
assert.equal(filled.activity.watchTime.length, 30);
|
||||
|
||||
const compact = getTrendsDashboard(db, '30d', 'day', false);
|
||||
// Only the two active days survive; no zero-filled gaps.
|
||||
assert.equal(compact.activity.watchTime.length, 2);
|
||||
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
||||
{ timeout: 20_000 },
|
||||
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
|
||||
// months, so January is zero-filled rather than dropped.
|
||||
assert.equal(dashboard.activity.watchTime.length, 3);
|
||||
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
|
||||
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
|
||||
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
|
||||
}
|
||||
|
||||
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
|
||||
// selected range, so charts render one point per calendar bucket instead of
|
||||
// silently collapsing to only the buckets that have activity. Returns null for
|
||||
// the unbounded "all" range, where builders fall back to the buckets in data.
|
||||
function buildBucketAxis(
|
||||
db: DatabaseSync,
|
||||
groupBy: TrendGroupBy,
|
||||
cutoffMs: string | null,
|
||||
referenceMs: string,
|
||||
): number[] | null {
|
||||
if (cutoffMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (groupBy === 'month') {
|
||||
const startKey = getLocalMonthKey(db, cutoffMs);
|
||||
const endKey = getLocalMonthKey(db, referenceMs);
|
||||
const keys: number[] = [];
|
||||
let year = Math.floor(startKey / 100);
|
||||
let month = startKey % 100;
|
||||
const endYear = Math.floor(endKey / 100);
|
||||
const endMonth = endKey % 100;
|
||||
while (year < endYear || (year === endYear && month <= endMonth)) {
|
||||
keys.push(year * 100 + month);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const startDay = getLocalEpochDay(db, cutoffMs);
|
||||
const endDay = getLocalEpochDay(db, referenceMs);
|
||||
const keys: number[] = [];
|
||||
for (let day = startDay; day <= endDay; day += 1) {
|
||||
keys.push(day);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Project a bucket→value map onto the axis, filling absent buckets with zero.
|
||||
// Without an axis (the "all" range) it falls back to the populated buckets in
|
||||
// ascending order, preserving the previous behaviour.
|
||||
function fillAxisPoints(
|
||||
axis: number[] | null,
|
||||
valueByBucket: Map<number, number>,
|
||||
): TrendChartPoint[] {
|
||||
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
|
||||
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
|
||||
}
|
||||
|
||||
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
let sum = 0;
|
||||
return points.map((point) => {
|
||||
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
});
|
||||
}
|
||||
|
||||
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
|
||||
const byKey = new Map<
|
||||
number,
|
||||
{ activeMin: number; cards: number; words: number; sessions: number }
|
||||
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||
}
|
||||
|
||||
return Array.from(byKey.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([key, value]) => ({
|
||||
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
|
||||
return keys.map((key) => {
|
||||
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
|
||||
return {
|
||||
label: makeTrendLabel(key),
|
||||
activeMin: Math.round(value.activeMin),
|
||||
cards: value.cards,
|
||||
words: value.words,
|
||||
sessions: value.sessions,
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
||||
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
||||
}));
|
||||
}
|
||||
|
||||
function dayLabel(epochDay: number): string {
|
||||
const { month, day } = dayPartsFromEpochDay(epochDay);
|
||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||
}
|
||||
|
||||
function buildSessionSeriesByDay(
|
||||
function buildSessionSeries(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, number>();
|
||||
const byBucket = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
|
||||
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
|
||||
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
||||
}
|
||||
|
||||
function buildSessionSeriesByMonth(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
function buildLookupsPerHundredWords(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const lookupsByBucket = new Map<number, number>();
|
||||
const wordsByBucket = new Map<number, number>();
|
||||
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(lookupsByBucket.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([bucketKey, lookups]) => {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
return {
|
||||
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
|
||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
||||
};
|
||||
});
|
||||
const ratioByBucket = new Map<number, number>();
|
||||
for (const [bucketKey, lookups] of lookupsByBucket) {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
|
||||
}
|
||||
return fillAxisPoints(axis, ratioByBucket);
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEpisodesPerDayFromDailyRollups(
|
||||
function buildEpisodesSeriesFromRollups(
|
||||
rollups: ImmersionSessionRollupRow[],
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, Set<number>>();
|
||||
const byBucket = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byDay.set(rollup.rollupDayOrMonth, videoIds);
|
||||
byBucket.set(rollup.rollupDayOrMonth, videoIds);
|
||||
}
|
||||
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, videoIds]) => ({
|
||||
label: dayLabel(epochDay),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byMonth.set(rollup.rollupDayOrMonth, videoIds);
|
||||
const counts = new Map<number, number>();
|
||||
for (const [bucketKey, videoIds] of byBucket) {
|
||||
counts.set(bucketKey, videoIds.size);
|
||||
}
|
||||
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, videoIds]) => ({
|
||||
label: makeTrendLabel(monthKey),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
return fillAxisPoints(axis, counts);
|
||||
}
|
||||
|
||||
function getTrendSessionMetrics(
|
||||
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerDay(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: string | null,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: dayLabel(row.epochDay),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerMonth(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: string | null,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: makeTrendLabel(row.monthKey),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
export function getTrendsDashboard(
|
||||
db: DatabaseSync,
|
||||
range: TrendRange = '30d',
|
||||
groupBy: TrendGroupBy = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): TrendsDashboardQueryResult {
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
||||
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
|
||||
const useMonthlyBuckets = groupBy === 'month';
|
||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||
// A null axis makes the builders fall back to only the buckets present in the
|
||||
// data; the contiguous axis zero-fills every calendar bucket in the window.
|
||||
const bucketAxis = fillEmptyBuckets
|
||||
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
|
||||
: null;
|
||||
|
||||
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
|
||||
dailyRollups.map((rollup) => rollup.videoId),
|
||||
);
|
||||
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
|
||||
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||
const activity = {
|
||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
useMonthlyBuckets
|
||||
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
|
||||
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
buildEpisodesSeriesFromRollups(
|
||||
useMonthlyBuckets ? monthlyRollups : dailyRollups,
|
||||
bucketAxis,
|
||||
),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
|
||||
cardsPerHour: efficiency.cardsPerHour,
|
||||
readingSpeed: efficiency.readingSpeed,
|
||||
},
|
||||
|
||||
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||
dismissModalUi?: () => void;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
dismissModalUi: options.dismissModalUi,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
options.sendKikuFieldGroupingRequest
|
||||
? options.sendKikuFieldGroupingRequest(data)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||
import { updateOverlayWindowBounds } from './overlay-window';
|
||||
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
|
||||
|
||||
export interface OverlayManager {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
@@ -9,7 +10,7 @@ export interface OverlayManager {
|
||||
setModalWindow: (window: BrowserWindow | null) => void;
|
||||
getOverlayWindow: () => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
let visibleOverlayVisible = false;
|
||||
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
||||
|
||||
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
|
||||
const updateWindowBounds = (
|
||||
geometry: WindowGeometry,
|
||||
window: BrowserWindow | null,
|
||||
): HyprlandPlacementStatus => {
|
||||
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
||||
applyOverlayBounds(geometry, window, { promote });
|
||||
return applyOverlayBounds(geometry, window, { promote });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
setOverlayWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, mainWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, modalWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
|
||||
@@ -30,6 +30,7 @@ type CreateAnkiIntegrationArgs = {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
};
|
||||
|
||||
export type OverlayWindowTrackerOptions = {
|
||||
@@ -72,6 +73,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
|
||||
args.showOverlayNotification,
|
||||
args.getCachedMediaPath,
|
||||
args.shouldRequireRemoteMediaCache,
|
||||
args.getYoutubeMediaSourceUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +146,7 @@ export function initializeOverlayRuntime(
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
backendOverride: string | null;
|
||||
@@ -183,6 +186,7 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
kind: 'audio' | 'video',
|
||||
) => Promise<string | null>;
|
||||
shouldRequireRemoteMediaCache?: () => boolean;
|
||||
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
shouldStartAnkiIntegration?: () => boolean;
|
||||
createAnkiIntegration?: (args: CreateAnkiIntegrationArgs) => AnkiIntegrationLike;
|
||||
}): boolean {
|
||||
@@ -221,6 +225,9 @@ export function initializeOverlayAnkiIntegration(options: {
|
||||
...(options.shouldRequireRemoteMediaCache
|
||||
? { shouldRequireRemoteMediaCache: options.shouldRequireRemoteMediaCache }
|
||||
: {}),
|
||||
...(options.getYoutubeMediaSourceUrl
|
||||
? { getYoutubeMediaSourceUrl: options.getYoutubeMediaSourceUrl }
|
||||
: {}),
|
||||
});
|
||||
if (options.shouldStartAnkiIntegration?.() !== false) {
|
||||
integration.start();
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
handleOverlayWindowBlurred,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
|
||||
import {
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
type HyprlandPlacementStatus,
|
||||
} from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
|
||||
options: {
|
||||
promote?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
if (!geometry || !window || window.isDestroyed()) return;
|
||||
): HyprlandPlacementStatus {
|
||||
if (!geometry || !window || window.isDestroyed()) {
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||
geometry,
|
||||
process.platform,
|
||||
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
|
||||
window,
|
||||
);
|
||||
window.setBounds(bounds);
|
||||
ensureHyprlandWindowFloatingByTitle({
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: window.getTitle(),
|
||||
bounds,
|
||||
promote: options.promote,
|
||||
|
||||
@@ -81,6 +81,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||
return raw === 'month' ? 'month' : 'day';
|
||||
}
|
||||
|
||||
// Defaults to true (zero-fill empty calendar buckets); only an explicit
|
||||
// "false" opts into the compact, data-only view.
|
||||
function parseTrendFillEmpty(raw: string | undefined): boolean {
|
||||
return raw !== 'false';
|
||||
}
|
||||
|
||||
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = raw
|
||||
@@ -685,7 +691,8 @@ export function createStatsApp(
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy));
|
||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
@@ -1206,6 +1213,7 @@ export function createStatsApp(
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
@@ -1228,7 +1236,14 @@ export function createStatsApp(
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||
mediaGen.generateAudio(
|
||||
sourcePath,
|
||||
startSec,
|
||||
clampedEndSec,
|
||||
audioPadding,
|
||||
null,
|
||||
normalizeAudio,
|
||||
),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface TokenizerServiceDeps {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -77,7 +77,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -129,7 +129,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
|
||||
function getKnownWordLookup(
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): (text: string) => boolean {
|
||||
): (text: string, reading?: string) => boolean {
|
||||
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||
return () => false;
|
||||
}
|
||||
@@ -723,6 +723,7 @@ async function parseWithYomitanInternalParser(
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
headwordReading: token.headwordReading,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
partOfSpeech: posMetadata.partOfSpeech,
|
||||
|
||||
@@ -56,6 +56,70 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens passes dictionary-form reading so spelling collisions stay unknown', () => {
|
||||
// とこ (colloquial ところ) resolves to headword 床/とこ; a known 床/ゆか card
|
||||
// must not mark it known (#138 regression).
|
||||
const cache = new Map([['床', 'ゆか']]);
|
||||
const isKnownWord = (text: string, reading?: string): boolean => {
|
||||
if (!cache.has(text)) {
|
||||
return false;
|
||||
}
|
||||
return reading === undefined || cache.get(text) === reading;
|
||||
};
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'とこ',
|
||||
headword: '床',
|
||||
reading: 'とこ',
|
||||
headwordReading: 'とこ',
|
||||
endPos: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps inflected known words matched via headword reading', () => {
|
||||
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||
text === '行く' && (reading === undefined || reading === 'いく');
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '行きたい',
|
||||
headword: '行く',
|
||||
reading: 'いきたい',
|
||||
headwordReading: 'いく',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens omits reading for headword match when token lacks headword reading and is inflected', () => {
|
||||
// MeCab tokens have no dictionary-form reading; the surface reading of an
|
||||
// inflected form must not be compared against the note's dictionary reading.
|
||||
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||
text === '食べる' && reading === undefined;
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '食べた',
|
||||
headword: '食べる',
|
||||
reading: 'タベタ',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 3,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
@@ -123,6 +187,48 @@ test('annotateTokens falls back to reading for known-word matches when headword
|
||||
assert.equal(result[0]?.frequencyRank, 1895);
|
||||
});
|
||||
|
||||
test('annotateTokens ignores partial furigana readings for known-word fallback', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '待ち合わせてる',
|
||||
headword: '待ち合わせる',
|
||||
reading: 'まあ',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 7,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'まあ',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens reading fallback still matches kana surfaces with complete readings', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ください',
|
||||
headword: '下さい',
|
||||
reading: 'ください',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'ください',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens excludes frequency for particle/bound_auxiliary and pos1 exclusions', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
|
||||
@@ -25,7 +25,7 @@ const jlptLevelLookupCaches = new WeakMap<
|
||||
>();
|
||||
|
||||
export interface AnnotationStageDeps {
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
knownWordMatchMode: NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
}
|
||||
@@ -635,22 +635,83 @@ export function stripSubtitleAnnotationMetadata(
|
||||
return sharedStripSubtitleAnnotationMetadata(token, options);
|
||||
}
|
||||
|
||||
function computeTokenKnownStatus(
|
||||
token: MergedToken,
|
||||
isKnownWord: (text: string) => boolean,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): boolean {
|
||||
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
||||
if (token.isKnown || (matchText ? isKnownWord(matchText) : false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedReading = token.reading.trim();
|
||||
if (!normalizedReading) {
|
||||
// Furigana-derived readings can be partial (kanji readings only, e.g. まあ for
|
||||
// 待ち合わせてる); matching those against known words produces false positives,
|
||||
// so the reading fallback requires a reading that plausibly covers the surface:
|
||||
// at least as many characters as the surface, with the surface's kana appearing
|
||||
// in order within the reading.
|
||||
function isCompleteReadingForSurface(surface: string, reading: string): boolean {
|
||||
const surfaceChars = [...normalizeJlptTextForExclusion(surface)];
|
||||
const readingChars = [...normalizeJlptTextForExclusion(reading)];
|
||||
if (readingChars.length < surfaceChars.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
|
||||
let cursor = 0;
|
||||
for (const char of surfaceChars) {
|
||||
if (!isKanaChar(char)) {
|
||||
continue;
|
||||
}
|
||||
const foundAt = readingChars.indexOf(char, cursor);
|
||||
if (foundAt === -1) {
|
||||
return false;
|
||||
}
|
||||
cursor = foundAt + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns the token's trimmed reading only when it plausibly covers the surface
|
||||
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
|
||||
// known-word reading disambiguation and the reading fallback stay in sync if the
|
||||
// validity rule changes.
|
||||
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
|
||||
const normalizedReading = token.reading.trim();
|
||||
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizedReading;
|
||||
}
|
||||
|
||||
// Reading to disambiguate the known-word text match, or undefined when the
|
||||
// token has no reading that describes the match text: in headword mode an
|
||||
// inflected surface's reading does not match the dictionary form's reading,
|
||||
// and partial furigana readings (see isCompleteReadingForSurface) would cause
|
||||
// false negatives. Undefined falls back to text-only matching (fail-open).
|
||||
function resolveKnownWordReadingForMatch(
|
||||
token: MergedToken,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): string | undefined {
|
||||
if (knownWordMatchMode === 'headword') {
|
||||
const headwordReading = token.headwordReading?.trim();
|
||||
if (headwordReading) {
|
||||
return headwordReading;
|
||||
}
|
||||
if (token.surface !== token.headword) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveCompleteTokenReading(token);
|
||||
}
|
||||
|
||||
function computeTokenKnownStatus(
|
||||
token: MergedToken,
|
||||
isKnownWord: (text: string, reading?: string) => boolean,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): boolean {
|
||||
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
||||
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
|
||||
if (token.isKnown || (matchText ? isKnownWord(matchText, matchReading) : false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fallbackReading = resolveCompleteTokenReading(token);
|
||||
if (!fallbackReading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
|
||||
}
|
||||
|
||||
function filterTokenFrequencyRank(
|
||||
|
||||
@@ -964,8 +964,9 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '潜み',
|
||||
reading: 'ひそ',
|
||||
reading: 'ひそみ',
|
||||
headword: '潜む',
|
||||
headwordReading: 'ひそむ',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
isNameMatch: false,
|
||||
@@ -974,6 +975,73 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [{ name: 'JPDBv2㋕', enabled: true, id: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens('待ち合わせてる', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
if (!text.startsWith('待ち合わせてる')) {
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
originalTextLength: 7,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '待ち合わせる',
|
||||
reading: 'まちあわせる',
|
||||
sources: [{ originalText: '待ち合わせてる', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '待ち合わせてる',
|
||||
reading: 'まちあわせてる',
|
||||
headword: '待ち合わせる',
|
||||
headwordReading: 'まちあわせる',
|
||||
startPos: 0,
|
||||
endPos: 7,
|
||||
isNameMatch: false,
|
||||
frequencyRank: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens uses frequency from later exact-match entry when first exact entry has none', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
@@ -1073,6 +1141,7 @@ test('requestYomitanScanTokens uses frequency from later exact-match entry when
|
||||
surface: '者',
|
||||
reading: 'もの',
|
||||
headword: '者',
|
||||
headwordReading: 'もの',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isNameMatch: false,
|
||||
@@ -1174,6 +1243,7 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
|
||||
surface: '者',
|
||||
reading: 'もの',
|
||||
headword: '者',
|
||||
headwordReading: 'もの',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isNameMatch: false,
|
||||
@@ -1274,6 +1344,7 @@ test('requestYomitanScanTokens uses exact frequency entry when selected reading
|
||||
surface: '第二',
|
||||
reading: 'だいに',
|
||||
headword: '第二',
|
||||
headwordReading: 'だいに',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
isNameMatch: false,
|
||||
|
||||