mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3091d7b2a7
|
||
|
|
4423dd56af
|
||
|
|
a6df21bdc2
|
||
|
|
e5a6e3b178
|
||
|
|
152d2c0626
|
||
|
|
bbb0e9b6a5
|
||
|
|
e09a416422
|
||
|
|
42f86b0748
|
||
|
|
e69cd5ea06
|
||
|
|
0cff52f8e4
|
||
|
|
b52692d826
|
||
|
|
4ab5e37bc5
|
||
|
|
f00b830054
|
||
|
|
ec73157f37
|
||
|
|
45212c7659
|
||
|
|
b710822db2
|
||
|
|
b347fdf21a
|
||
|
|
a4325740b7
|
||
|
|
4648c5e5a1
|
||
|
|
72d0b19d94
|
||
|
|
c627095e6e
|
||
|
|
dea1136170
|
||
|
|
874d2c9f1a
|
||
|
|
52fc1b63e9
|
||
|
|
da5074761f
|
||
|
|
f7230db90c
|
||
|
|
92ef305e8c
|
||
|
|
504faa51e0
|
||
|
|
c10174f9cd
|
||
|
|
33e694bc61
|
||
|
|
430120cc63
|
||
|
|
294e3a777e
|
||
|
|
c673b71053
|
||
|
|
d31a28c54f
|
||
|
|
d100789232
|
||
|
|
9a5fb02cbd
|
||
|
|
2ba820f41a
|
||
|
|
c39dbb5a8d
|
||
|
|
2958591ec9
|
||
|
|
c9880efee3
|
||
|
|
f225ac3c89
|
||
|
|
cacaa2b371
|
||
|
|
9fe4c7eb33
|
||
|
|
ab5e50adac
|
||
|
|
15c74ef502
|
||
|
|
f0d3c95b4c
|
||
|
|
d7e1d7edc6
|
||
|
|
5354fdf7b9
|
||
|
|
d9fdc7ef6d
|
No files matched your search
@@ -20,6 +20,8 @@ coverage/
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
/build/hachidori/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
[submodule "vendor/subminer-yomitan"]
|
||||
path = vendor/subminer-yomitan
|
||||
url = https://github.com/ksyasuda/subminer-yomitan
|
||||
[submodule "vendor/hachidori"]
|
||||
path = vendor/hachidori
|
||||
url = https://github.com/ksyasuda/hachidori.git
|
||||
branch = subminer
|
||||
@@ -1,55 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
### Changed
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
### Fixed
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv
|
||||
Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -25,6 +25,8 @@ Look up words with Yomitan, mine them to Anki, and track your immersion without
|
||||
|
||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||
|
||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
</div>
|
||||
@@ -265,7 +267,8 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: app
|
||||
|
||||
- SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: docs
|
||||
area: docs
|
||||
|
||||
- Rewrote the docs site to be shorter and easier to scan: pages lead with setup and use, reference material lives in compact tables, and internal detail was cut from user pages.
|
||||
- The configuration reference now has a short explanation and a key/default table for each config block.
|
||||
- Fixed docs that no longer matched current behavior.
|
||||
- Fixed the Arch Linux install command for MeCab, which is only in the AUR (`mecab-git`), and brought the README requirements and quick start in line with the installation guide.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: dictionary
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43.7.7 and added profile guards that block unsupported runtimes and Electron major or minor downgrades before Yomitan storage is loaded. Patch downgrades within the same major and minor version are allowed.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
|
||||
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Media timing review now keeps playback paused while it is open when the dictionary popup or subtitle hover pause ends, as with Hachidori popups closing when the review appears. Playback resumes after the review closes unless a dictionary popup is still open.
|
||||
@@ -0,0 +1,14 @@
|
||||
type: added
|
||||
area: dictionary
|
||||
|
||||
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
|
||||
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` opens Yomitan settings unless a read-only external Yomitan profile is configured. This restriction also applies with Hachidori active, without blocking Hachidori settings.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Linked Docker hosts receive character dictionary uploads at the linked machine's management port, with `hachidori.externalHostManagementUrl` as an optional override, retry busy imports, and replace the previous dictionary only after a successful import. Browser and app hosts report where to import the dictionary manually.
|
||||
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates apart from the deck, which follows `ankiConnect.deck` so polling mode enriches Hachidori cards. Anki discovery retries after an unavailable connection.
|
||||
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
|
||||
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
|
||||
- First-run setup remembers each backend that finished it, including when reopened for legacy plugin cleanup, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. A running Yomitan session keeps using its external profile until it restarts into Hachidori.
|
||||
- Stats dashboard mining and deck lookup use the selected backend, with the Anki proxy enabled or disabled. Dashboard cards retain the selected history line and media instead of being processed again with the current mpv subtitle, with both Yomitan and Hachidori. Stats cards carry a `SubMiner::Stats` tag so polling also preserves their context. Hachidori word mining supplies dictionary aliases, IDs, and frequency metadata to its native Anki renderer. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
|
||||
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
|
||||
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining, media enrichment, and custom toolbar buttons stay in SubMiner, including when editing buttons while linked.
|
||||
- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: dictionary
|
||||
|
||||
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keep the subtitle overlay mapped during fullscreen transitions on Hyprland, avoiding focus changes from repeated hide/show calls that can cancel mpv fullscreen entry. Preserve the hide/show refresh on Sway.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay (subtitles and sidebar) becoming unclickable after mpv goes fullscreen on Hyprland with `mpv.backend: x11`. The launcher no longer strips `HYPRLAND_INSTANCE_SIGNATURE` from the X11 environment, so the overlay can lift Hyprland's fullscreen input block again.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
|
||||
- Jellyfin episodes with image-based embedded subtitles (PGS, DVD, DVB) now auto-select the Japanese and English tracks. SubMiner no longer requests those tracks as text, and a subtitle track that fails to download no longer cancels selection of the others.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Timing review previews and mined card audio now use the audio track you are playing on Jellyfin direct-play streams, instead of the track Jellyfin marks as default.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- Fixed the command-line launcher showing "Not on PATH" on macOS when SubMiner was opened from Finder or the Dock, even though the launcher directory was already on your terminal PATH.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed mpv key bindings (input.conf and mpv defaults, e.g. `9`/`0` volume) doing nothing while the overlay had focus when the overlay loaded before SubMiner connected to mpv, common in `mpv.backend: x11` mode. The overlay now reloads mpv's bindings once mpv connects.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`, with modifiers) to `keybindings`, including capture from the settings key editor.
|
||||
- Mouse button and scroll wheel bindings from mpv (`input.conf` and mpv defaults, e.g. double-click fullscreen, wheel volume, back/forward for playlist) now work while the cursor is over the overlay. On Hyprland, where the overlay always receives input, they previously did nothing. SubMiner bindings and the right-click pause still take priority.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: dependencies
|
||||
|
||||
- Updated Electron to 42.10.0 and undici to 7.29.1 to resolve the security advisories blocking CI.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: annotations
|
||||
|
||||
- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay.
|
||||
- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Session help can run the highlighted command during playback: press `Enter` or double-click a row. Hovering a row with the mouse makes it the `Enter` target. Help opened from the tray with no video loaded stays read-only.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS with Bluetooth headphones such as AirPods, the timing review preview cursor now moves smoothly across the selection instead of stuttering and jumping to the end partway through. This affected Opus audio, including Jellyfin streams.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Media timing review trim handles are now thin lines centered on the clip edge, so it's clear where the clip starts and ends and less of the waveform is covered.
|
||||
- Original subtitle boundaries use orange bars without text tags, leaving the waveform unobstructed.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added a YouTube browser window (`subminer youtube`/`subminer yt`, or tray **Browse YouTube**) that keeps your YouTube login across restarts.
|
||||
- Clicking a video plays it in mpv instead of the page; middle-click, Shift/Ctrl-click, or the right-click menu queues it in mpv's playlist.
|
||||
- Queued YouTube videos get the same Japanese subtitle setup when mpv reaches them.
|
||||
- Closing the YouTube window quits a SubMiner started by `subminer youtube` (after mpv closes, if a video is still playing).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- YouTube Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track; SubMiner now picks the real track and falls back to yt-dlp if a direct subtitle download is refused.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: youtube
|
||||
|
||||
- Added `youtube.subtitleSource`: set it to `whisper` to transcribe YouTube videos with Whisper instead of downloading YouTube's captions. The default stays `youtube`.
|
||||
- Whisper mode keeps the video paused while it transcribes, with progress in the subtitle generation modal; close the modal to keep watching, or cancel to continue without subtitles.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos, whatever `youtube.subtitleSource` is set to.
|
||||
- Whisper mode downloads a small audio-only stream and deletes it once generation ends, or when you switch videos, close mpv, or quit.
|
||||
@@ -6,6 +6,22 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.
|
||||
}, // Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
@@ -15,9 +15,9 @@ If you changed AnkiConnect's port, set `ankiConnect.url` to match.
|
||||
|
||||
## How cards get filled
|
||||
|
||||
When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
|
||||
|
||||
Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
|
||||
@@ -42,6 +42,20 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki
|
||||
|
||||
- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
|
||||
- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
|
||||
- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio.
|
||||
|
||||
### Hachidori settings from SubMiner
|
||||
|
||||
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
|
||||
|
||||
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
|
||||
- Configured tags go into untouched defaults.
|
||||
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
|
||||
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
|
||||
|
||||
Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry.
|
||||
|
||||
Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this.
|
||||
|
||||
### Proxy troubleshooting
|
||||
|
||||
@@ -87,6 +101,8 @@ If cards are not getting filled:
|
||||
|
||||
Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
|
||||
|
||||
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
||||
|
||||
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
|
||||
|
||||
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
||||
@@ -122,6 +138,8 @@ Media settings apply to the next card without a restart.
|
||||
|
||||
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
|
||||
|
||||
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
|
||||
|
||||
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
|
||||
|
||||
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
|
||||
@@ -208,6 +226,8 @@ For Senren, use the same keys under `isSenren`.
|
||||
| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
|
||||
| `manual` | Show both cards, let you choose which to keep and preview the merge |
|
||||
|
||||
Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media.
|
||||
|
||||
The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
**Added**
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
**Changed**
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
**Fixed**
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
**Docs**
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -67,6 +67,14 @@ Everything else needs a restart.
|
||||
|
||||
## Core settings
|
||||
|
||||
### Dictionary backend
|
||||
|
||||
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
|
||||
|
||||
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
|
||||
|
||||
`hachidori.externalHostManagementUrl` overrides the management origin that character dictionaries upload to when Hachidori is linked to a Docker host. Leave it empty to use the linked host's machine on the Docker management port. Set it only for a non-default `ADMIN_PORT` or a reverse proxy, and use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup).
|
||||
|
||||
### Logging
|
||||
|
||||
Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
|
||||
|
||||
@@ -42,8 +42,6 @@ To share stats and watch history between machines, use [`subminer sync <host>`](
|
||||
|
||||
Recent sessions, a streak calendar, watch-time history, and totals for completed episodes and shows.
|
||||
|
||||
Deleting a session or session group refreshes the Overview totals, charts, calendar, and vocabulary summary automatically. If a refresh fails, Overview keeps the previous totals visible and offers **Retry**.
|
||||
|
||||

|
||||
|
||||
### Library
|
||||
@@ -91,11 +89,13 @@ Searches the primary subtitle lines and titles in your history. **Search by head
|
||||
|
||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
|
||||
|
||||
- **Mine Word**: full Yomitan lookup for the word, plus sentence, audio, and image.
|
||||
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
||||
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
||||
- **Mine Audio**: an audio card with `IsAudioCard` set.
|
||||
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs Yomitan dictionaries.
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs dictionaries in the selected backend.
|
||||
|
||||
Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, SubMiner uses it to keep the chosen history line when polling picks up the card.
|
||||
|
||||
## Repeated lines
|
||||
|
||||
@@ -124,6 +124,4 @@ Leave out `--lookback-days` to scan all history. Word and kanji counts are corre
|
||||
|
||||
By default SubMiner keeps everything. To limit history, set `immersionTracking.retentionPreset` to `minimal`, `balanced`, or `deep-history`, or set the `immersionTracking.retention.*Days` values yourself (`0` keeps all). Lifetime totals and vocabulary counts are stored separately and stay exact when old sessions are pruned.
|
||||
|
||||
Deleting retained sessions preserves earlier word and kanji discovery dates from pruned history. New occurrences record observation time so deletion can update their dates exactly. Older history used write time, so an earlier discovery date is preserved when its origin cannot be determined.
|
||||
|
||||
See [Immersion tracking](/configuration#immersion-tracking) and [Stats dashboard](/configuration#stats-dashboard) in the config reference for every option and default.
|
||||
@@ -6,6 +6,22 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.
|
||||
}, // Override the linked Docker host management URL only for a non-default port or a reverse proxy.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
+16
-16
@@ -6,12 +6,12 @@ Shortcuts work when the overlay has focus. With the [mpv plugin](/mpv-plugin), `
|
||||
|
||||
## Global
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------- | ---------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------- | ------------------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | Fixed |
|
||||
|
||||
`Alt+Shift+Y` is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
`Alt+Shift+Y` opens Yomitan or Hachidori settings, whichever backend is running. It is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
|
||||
## Mining
|
||||
|
||||
@@ -98,17 +98,17 @@ The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Be
|
||||
|
||||
Press `y`, then the second key.
|
||||
|
||||
| Keys | Action |
|
||||
| ----- | -------------------------- |
|
||||
| `y-y` | Open the SubMiner menu |
|
||||
| `y-s` | Start the overlay |
|
||||
| `y-S` | Stop the overlay |
|
||||
| `y-t` | Toggle the visible overlay |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart the overlay |
|
||||
| `y-c` | Show overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `v` | Cycle primary subtitle bar |
|
||||
| Keys | Action |
|
||||
| ----- | ------------------------------- |
|
||||
| `y-y` | Open the SubMiner menu |
|
||||
| `y-s` | Start the overlay |
|
||||
| `y-S` | Stop the overlay |
|
||||
| `y-t` | Toggle the visible overlay |
|
||||
| `y-o` | Open active dictionary settings |
|
||||
| `y-r` | Restart the overlay |
|
||||
| `y-c` | Show overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `v` | Cycle primary subtitle bar |
|
||||
|
||||
The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay has focus, `y` then `d` toggles DevTools.
|
||||
|
||||
|
||||
+40
-9
@@ -16,10 +16,41 @@ Run `subminer` with no file to pick one from the current directory instead. See
|
||||
|
||||
### Yomitan setup
|
||||
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan. First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan (or in Hachidori, if you [switched backends](#hachidori-setup)). First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
|
||||
The bundled Yomitan is separate from any Yomitan in your browser. It has its own dictionaries and settings.
|
||||
|
||||
### Hachidori setup
|
||||
|
||||
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||
|
||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over.
|
||||
|
||||
Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected.
|
||||
|
||||
Switching backends:
|
||||
|
||||
- First-run setup asks for dictionaries the first time you switch to a backend. Switching back to a backend that already finished setup skips it.
|
||||
- Until you restart, SubMiner keeps running the backend it started with. The launcher waits for that backend before playback and logs a restart reminder.
|
||||
- `--yomitan` and `--hachidori` both work whichever backend is selected. Opening settings does not switch backends.
|
||||
- When `yomitan.externalProfilePath` is set, `--yomitan` is disabled to keep the external profile read-only. Hachidori settings still open.
|
||||
|
||||
#### External dictionary host
|
||||
|
||||
First-run setup can link a Hachidori host instead of using local dictionaries: **Dictionary source → Use an external dictionary host → Link host**. Turn on sharing in the other Hachidori app or browser, or start a compatible Docker host, then enter its sharing address, for example `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port.
|
||||
|
||||
| Host | Must be running |
|
||||
| -------- | ------------------------------------------- |
|
||||
| Browser | The browser, the Hachidori extension, relay |
|
||||
| Electron | The host app and any relay it needs |
|
||||
| Docker | The container only |
|
||||
|
||||
Setup checks the connection and the host's dictionaries before **Finish** unlocks, so import at least one dictionary on the host and refresh. The link survives restarts. **Unlink and use local dictionaries** goes back to local.
|
||||
|
||||
While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them.
|
||||
|
||||
When linked to a Docker host, SubMiner uploads [character dictionaries](/character-dictionary) to the linked machine's management port (8780) and replaces the previous dictionary once the import succeeds, retrying while the host is busy. If the host uses a different `ADMIN_PORT` or sits behind a reverse proxy, set `hachidori.externalHostManagementUrl` to its management origin, for example `"http://pve-main:9000"`. This is not the WebSocket sharing address. Browser and app hosts cannot receive uploads over the link, so sync reports where the merged ZIP is and you import it from that host's Hachidori settings. Local Hachidori does not need this setting.
|
||||
|
||||
## Picking files
|
||||
|
||||
```bash
|
||||
@@ -33,15 +64,15 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
|
||||
|
||||
## Overlay basics
|
||||
|
||||
| Key | Action |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings (works from any window, not configurable) |
|
||||
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
|
||||
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
|
||||
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
|
||||
| Key | Action |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
|
||||
| `Alt+Shift+Y` | Open Yomitan or Hachidori settings (works from any window, not configurable) |
|
||||
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
|
||||
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
|
||||
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
|
||||
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open Yomitan popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
|
||||
You can drop files onto the overlay:
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
|
||||
@@ -63,6 +63,7 @@ bun run docs:build
|
||||
Content checks run inside the electron-builder afterPack hook. See the
|
||||
[release guide](../RELEASING.md#package-contents-checks) for the
|
||||
installed-app verification checklist.
|
||||
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux. It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
import {
|
||||
hasLauncherExternalYomitanProfileConfig,
|
||||
loadLauncherDictionaryBackend,
|
||||
} from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: loadLauncherDictionaryBackend(),
|
||||
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
|
||||
warn: (message) => log('warn', args.logLevel, message),
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
hasLegacyMpvPlugin: () =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from './log.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import type {
|
||||
Args,
|
||||
LauncherLoggingConfig,
|
||||
@@ -100,6 +101,12 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLauncherDictionaryBackend(): DictionaryBackend {
|
||||
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
|
||||
? 'hachidori'
|
||||
: 'yomitan';
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,10 @@ export function applyRootOptionsToArgs(
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.yomitan === true || options.hachidori === true) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
|
||||
}
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
|
||||
@@ -87,6 +87,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('--yomitan', 'Open Yomitan settings window')
|
||||
.option('--hachidori', 'Open Hachidori settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
|
||||
+73
-20
@@ -282,29 +282,31 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher settings option forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
for (const flag of ['--settings', '--yomitan', '--hachidori']) {
|
||||
test(`launcher ${flag} forwards the matching app settings command`, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--settings'], env);
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher([flag], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('launcher settings command forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
@@ -1131,3 +1133,54 @@ test('classifyJellyfinChildSelection keeps container drilldown state instead of
|
||||
id: 'season-2',
|
||||
});
|
||||
});
|
||||
|
||||
test('external Yomitan profile remains available while a running app awaits a backend switch', () => {
|
||||
withTempDir((dir) => {
|
||||
const env = makeTestEnv(dir, path.join(dir, 'config'));
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: env.APPDATA,
|
||||
xdgConfigHome: env.XDG_CONFIG_HOME,
|
||||
homeDir: dir,
|
||||
existsSync: () => false,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
dictionaryBackend: 'hachidori',
|
||||
yomitan: { externalProfilePath: '/external/yomitan-profile' },
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--eval',
|
||||
`
|
||||
import assert from 'node:assert/strict';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from './launcher/config.ts';
|
||||
import { ensureLauncherSetupReady } from './launcher/setup-gate.ts';
|
||||
import { createDefaultSetupState } from './src/shared/setup-state.ts';
|
||||
for (const running of [true, false]) {
|
||||
let launches = 0;
|
||||
let tick = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => running,
|
||||
readSetupState: () => ({ ...createDefaultSetupState(), dictionaryBackend: 'yomitan' }),
|
||||
isExternalYomitanConfigured: hasLauncherExternalYomitanProfileConfig,
|
||||
launchSetupApp: () => { launches += 1; },
|
||||
sleep: async () => {},
|
||||
now: () => tick++,
|
||||
timeoutMs: 2,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, running);
|
||||
assert.equal(launches, running ? 0 : 1);
|
||||
}
|
||||
`,
|
||||
],
|
||||
{ cwd: process.cwd(), env, encoding: 'utf8', timeout: LAUNCHER_RUN_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
@@ -353,3 +353,12 @@ test('parseArgs requires an explicit logs action', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Logs command requires -e or --export/);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`parseArgs forwards ${flag} to the app`, () => {
|
||||
const parsed = parseArgs([flag], 'subminer', {});
|
||||
assert.equal(parsed.appPassthrough, true);
|
||||
assert.deepEqual(parsed.appArgs, [flag]);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
}
|
||||
+104
-2
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
import {
|
||||
ensureLauncherSetupReady,
|
||||
resolveLauncherGateBackend,
|
||||
waitForSetupCompletion,
|
||||
} from './setup-gate';
|
||||
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
|
||||
|
||||
const commandLineSetupDefaults = {
|
||||
bunInstallStatus: 'unknown',
|
||||
@@ -295,3 +299,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
|
||||
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
|
||||
let launched = 0;
|
||||
let polls = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => state,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
launched += 1;
|
||||
},
|
||||
sleep: async () => {
|
||||
polls += 1;
|
||||
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
|
||||
},
|
||||
now: () => polls,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
assert.equal(launched, 1);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('matching Hachidori completion resumes playback without launching setup', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'completed',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('a backend that finished setup earlier passes the gate after switching back', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'yomitan',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'incomplete',
|
||||
completedDictionaryBackends: ['yomitan'],
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gate follows the running app backend until it restarts into the configured one', async () => {
|
||||
const warnings: string[] = [];
|
||||
const state = {
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'yomitan' as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
'yomitan',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => false,
|
||||
}),
|
||||
'hachidori',
|
||||
);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => true,
|
||||
readSetupState: () => state,
|
||||
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
+47
-6
@@ -1,7 +1,13 @@
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import {
|
||||
getSetupStateDictionaryBackend,
|
||||
isSetupCompleted,
|
||||
type SetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
if (isSetupCompleted(state, deps.dictionaryBackend)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
isSetupCompleted(state, deps.dictionaryBackend) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
/**
|
||||
* The app pins its dictionary backend at startup while the config file can change
|
||||
* underneath it. When an app is already running, gate on the backend it recorded
|
||||
* in the setup state rather than the config value it has not restarted into.
|
||||
*/
|
||||
export async function resolveLauncherGateBackend(deps: {
|
||||
configuredBackend: DictionaryBackend;
|
||||
state: SetupState | null;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<DictionaryBackend> {
|
||||
const runningBackend = deps.state
|
||||
? getSetupStateDictionaryBackend(deps.state)
|
||||
: deps.configuredBackend;
|
||||
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
|
||||
return deps.configuredBackend;
|
||||
}
|
||||
deps.warn?.(
|
||||
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
|
||||
);
|
||||
return runningBackend;
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
const dictionaryBackend = await resolveLauncherGateBackend({
|
||||
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
|
||||
state: initialState,
|
||||
isAppRunning: deps.isAppRunning,
|
||||
warn: deps.warn,
|
||||
});
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
dictionaryBackend,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
dictionaryBackend,
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
|
||||
+9
-3
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.20.1",
|
||||
"version": "0.20.1-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -12,6 +12,7 @@
|
||||
"get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test:dictionary:electron": "bun scripts/run-dictionary-settings-smoke.mjs && bun scripts/run-dictionary-settings-smoke.mjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||
@@ -21,7 +22,7 @@
|
||||
"build:launcher": "bun run scripts/build-launcher.ts",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
@@ -81,7 +82,8 @@
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
|
||||
"test:package": "bun scripts/run-package-smoke.mjs"
|
||||
"test:package": "bun scripts/run-package-smoke.mjs",
|
||||
"build:hachidori": "bun scripts/build-hachidori.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
@@ -232,6 +234,10 @@
|
||||
"from": "build/yomitan",
|
||||
"to": "yomitan"
|
||||
},
|
||||
{
|
||||
"from": "build/hachidori",
|
||||
"to": "hachidori"
|
||||
},
|
||||
{
|
||||
"from": "vendor/yomitan-jlpt-vocab",
|
||||
"to": "yomitan-jlpt-vocab"
|
||||
|
||||
+49
-55
@@ -1,76 +1,73 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
|
||||
|
||||
## Changes since v0.20.1-beta.1
|
||||
|
||||
- Upgraded the desktop runtime to Electron 43.7.7.
|
||||
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
|
||||
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
|
||||
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
|
||||
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
|
||||
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
|
||||
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
|
||||
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
|
||||
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
|
||||
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
|
||||
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
|
||||
<!-- prerelease-version: 0.20.1-beta.1 -->
|
||||
|
||||
## Highlights
|
||||
|
||||
### Added
|
||||
|
||||
- **YouTube Browser Window**:
|
||||
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start.
|
||||
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
|
||||
- Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts.
|
||||
- Clicking a video plays it in mpv instead of on the page.
|
||||
- Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist.
|
||||
- When a queued video starts, it gets the same Japanese subtitle setup.
|
||||
- If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
|
||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
|
||||
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`.
|
||||
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting.
|
||||
- Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit.
|
||||
- **Mouse and Scroll Wheel Bindings**:
|
||||
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
|
||||
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
|
||||
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
|
||||
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
|
||||
- `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor.
|
||||
- Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist.
|
||||
- Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before.
|
||||
- SubMiner's own bindings and right-click pause still win over mpv's.
|
||||
- **Session Help Commands**:
|
||||
- During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
- Hovering over a row makes it the target for `Enter`.
|
||||
- Session help opened from the tray with no video loaded is still read-only.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release Assets**: Releases no longer include package-size JSON reports.
|
||||
- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Jellyfin Subtitles**:
|
||||
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
|
||||
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
|
||||
- If one track fails to download, the other tracks are still selected.
|
||||
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
|
||||
- The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track.
|
||||
- Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles.
|
||||
- Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically.
|
||||
- If one subtitle track fails to download, the other tracks are still selected.
|
||||
- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default.
|
||||
- **Media Timing Review**:
|
||||
- Trim handles are now thin lines centered on the clip edges.
|
||||
- Original subtitle boundaries are now plain orange bars with no labels.
|
||||
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
|
||||
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
|
||||
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
|
||||
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
|
||||
- **Stats Accuracy**:
|
||||
- Sentence mining no longer counts each card twice in session and lifetime stats.
|
||||
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
|
||||
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
|
||||
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
|
||||
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
|
||||
- Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden.
|
||||
- The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible.
|
||||
- On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams.
|
||||
- **Hyprland Fullscreen**:
|
||||
- The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before.
|
||||
- With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen.
|
||||
- **mpv Key Bindings in the Overlay**:
|
||||
- mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus.
|
||||
- They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects.
|
||||
- **YouTube Japanese Subtitles**:
|
||||
- Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track.
|
||||
- If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window.
|
||||
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error.
|
||||
- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
|
||||
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
|
||||
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
|
||||
- **Docs Site Rewrite**:
|
||||
- Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables.
|
||||
- The configuration reference now gives each config block a short explanation and a table of keys and defaults.
|
||||
- Pages that no longer matched how SubMiner works have been corrected.
|
||||
- **Arch Linux Install**:
|
||||
- The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`.
|
||||
- The README's requirements and quick start now match the installation guide.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
@@ -80,17 +77,14 @@
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
See the [installation guide](https://docs.subminer.moe/installation) for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- Linux: `SubMiner-*.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube in a window that keeps your login across restarts. Open it with `subminer youtube` (or `subminer yt`), or choose **Browse YouTube** from the tray.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup when they start playing.
|
||||
- **Whisper Subtitles for YouTube**: Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions. The subtitle generation modal (`Ctrl+Shift+G`) now also works on YouTube videos.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay.
|
||||
- **Run Commands From Session Help**: Press `Enter` or double-click a row in session help to run that command during playback.
|
||||
|
||||
### Fixed
|
||||
- **YouTube**:
|
||||
- Japanese subtitles no longer fail with "HTTP 429" on videos that also have a machine-translated Japanese track.
|
||||
- On Windows, the subtitle generation modal now works during YouTube playback.
|
||||
- On Windows, playing a video queued from the YouTube browser window no longer clears the rest of mpv's queue.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, instead of waiting for every other track.
|
||||
- Japanese and English image-based subtitles (PGS, DVD, DVB) are now selected automatically.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Hyprland Overlay**: The overlay no longer interrupts mpv when it goes fullscreen. With `mpv.backend: x11`, the overlay stays clickable after mpv goes fullscreen.
|
||||
- **mpv Key Bindings**: Keys from mpv's defaults and your `input.conf` (such as `9` and `0` for volume) now work while the overlay has focus, even when the overlay loads before SubMiner connects to mpv.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and the original subtitle boundaries are plain orange bars. On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly.
|
||||
- **Stats**:
|
||||
- Each mined card is counted once instead of twice.
|
||||
- Overview totals, charts, the calendar, and the vocabulary summary update right after you delete sessions.
|
||||
- Word and kanji dates stay correct after deletes and pruning, and the new-words charts now agree with the vocabulary summary. Existing data is repaired in the background on the next launch.
|
||||
- **Yomitan Profile Safety**: Electron is updated to 43.7.7, and SubMiner now refuses to load your Yomitan data with an older or unsupported runtime. Automatic character-dictionary updates stop if a profile that had dictionaries suddenly reports none.
|
||||
- **Security Updates**: Updated Electron and undici to resolve security advisories.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens a window when launched inside a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when you stop the terminal command that started it with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, and the configuration reference now has a table of keys and defaults for each section. Outdated pages were corrected, and the Arch Linux MeCab install now uses the AUR package `mecab-git`.
|
||||
- **Stats**: Added docs on how the Overview refreshes after you delete sessions and how discovery dates are kept after pruning.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
|
||||
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
|
||||
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
|
||||
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
|
||||
- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274
|
||||
- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275
|
||||
- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276
|
||||
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
|
||||
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
|
||||
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
|
||||
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
|
||||
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
|
||||
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
|
||||
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
|
||||
|
||||
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
|
||||
|
||||
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
|
||||
@@ -44,12 +44,20 @@ function fragmentTypesInPrompt(input: string): string[] {
|
||||
.map((line) => line.slice('type: '.length).trim());
|
||||
}
|
||||
|
||||
function assertPromptRequestsConciseItems(input: string): void {
|
||||
assert.match(input, /Default to inline/);
|
||||
assert.match(input, /Use nested bullets only when an item has several distinct changes/);
|
||||
assert.match(input, /Never write paragraph-length bullets/);
|
||||
assert.match(input, /Merge aggressively: one item per feature or area/);
|
||||
assert.match(input, /Release notes summarize; the docs hold the details/);
|
||||
function assertPromptRequestsNestedBullets(input: string): void {
|
||||
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
|
||||
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
|
||||
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
|
||||
assert.match(input, /Avoid paragraph-style release-note bullets/);
|
||||
}
|
||||
|
||||
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
|
||||
assertPromptRequestsNestedBullets(input);
|
||||
assert.match(
|
||||
input,
|
||||
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
|
||||
);
|
||||
assert.match(input, /Do not require the exact nested labels/);
|
||||
}
|
||||
|
||||
function defaultPolishedBody(input: string): string {
|
||||
@@ -446,13 +454,14 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
assertPromptRequestsConciseItems(prompt);
|
||||
assertPromptRequestsNestedBullets(prompt);
|
||||
}
|
||||
|
||||
const releaseNotesPrompt = stub.calls.find(
|
||||
(call) => modeFromPrompt(call.input) === 'release-notes',
|
||||
);
|
||||
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
|
||||
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -808,11 +817,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
|
||||
prompt,
|
||||
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||
);
|
||||
assert.match(
|
||||
prompt,
|
||||
/shorten long baseline bullets and collapse nested lists that do not need to be nested/,
|
||||
);
|
||||
assertPromptRequestsConciseItems(prompt);
|
||||
assertReleaseNotesPromptRequestsNestedBullets(prompt);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
+16
-24
@@ -476,29 +476,21 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
||||
|
||||
</details>
|
||||
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
|
||||
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
||||
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
|
||||
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
|
||||
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
|
||||
5. Keep items brief. Release notes summarize; the docs hold the details.
|
||||
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
|
||||
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
|
||||
- Name a config key only when users must set it to use the change or to keep their current behavior.
|
||||
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
|
||||
4. Each top-level change item should:
|
||||
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
|
||||
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
|
||||
6. Match the length and density of this example:
|
||||
### Added
|
||||
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
|
||||
### Changed
|
||||
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
|
||||
### Fixed
|
||||
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
|
||||
- **Jellyfin 12**:
|
||||
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
|
||||
- Casting no longer drops after about a minute.
|
||||
- Mined cards get the episode title again.
|
||||
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
||||
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
|
||||
- **Playlist Browser**:
|
||||
- Saved shows now open without rescanning the library.
|
||||
- The picker remembers the last folder you browsed between launches.
|
||||
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
|
||||
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
|
||||
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
|
||||
Bullets inside the Internal section may stay single-level.
|
||||
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
|
||||
7. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||
|
||||
@@ -799,7 +791,7 @@ function polishFragmentsWithClaude(
|
||||
? [
|
||||
'## Existing Prerelease Notes',
|
||||
'',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
@@ -822,7 +814,7 @@ Rules:
|
||||
3. ADDED fragments describe changes that are new in this build; summarize them.
|
||||
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
|
||||
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
|
||||
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
|
||||
6. Keep bullets short and concrete. Use nested bullets sparingly.
|
||||
7. Do not invent changes. Every bullet must be grounded in the diffs.
|
||||
8. If no bullet survives rules 2-5, output exactly this single line:
|
||||
- No user-facing changes since PREVIOUS_TAG.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
||||
const output = path.join(repoRoot, 'build', 'hachidori');
|
||||
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
||||
|
||||
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
||||
// so ordinary app builds need neither Emscripten nor network access.
|
||||
for (const [file, expected] of Object.entries(provenance.artifacts)) {
|
||||
const actual = createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(source, file)))
|
||||
.digest('hex');
|
||||
if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`);
|
||||
}
|
||||
const extension = path.join(source, 'extension');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8'));
|
||||
for (const file of [
|
||||
manifest.options_page,
|
||||
manifest.background.service_worker,
|
||||
'offscreen.html',
|
||||
...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]),
|
||||
]) {
|
||||
if (!fs.existsSync(path.join(extension, file)))
|
||||
throw new Error(`Missing Hachidori asset: ${file}`);
|
||||
}
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.cpSync(extension, output, { recursive: true });
|
||||
// Host configuration belongs in the staged copy, leaving the fork usable in Chrome.
|
||||
const hostConfiguration = {
|
||||
'overlay-mode.js': [
|
||||
['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'],
|
||||
['customJavaScript: true,', 'customJavaScript: false,'],
|
||||
],
|
||||
// Overlay hosts seed the lookup highlight off because their Anki screenshot is
|
||||
// the see-through viewport. SubMiner captures media from mpv, and without the
|
||||
// highlight the sidebar shows nothing for the word being looked up.
|
||||
'setup-state.js': [
|
||||
[
|
||||
'lookupMode: "hover",\n sourceHighlightEnabled: false,',
|
||||
'lookupMode: "hover",\n sourceHighlightEnabled: true,',
|
||||
],
|
||||
],
|
||||
};
|
||||
for (const [file, replacements] of Object.entries(hostConfiguration)) {
|
||||
const filePath = path.join(output, file);
|
||||
let text = fs.readFileSync(filePath, 'utf8');
|
||||
for (const [original, replacement] of replacements) {
|
||||
if (!text.includes(original))
|
||||
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
|
||||
text = text.replace(original, replacement);
|
||||
}
|
||||
fs.writeFileSync(filePath, text);
|
||||
}
|
||||
manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts');
|
||||
// A fixed key pins the extension ID, so Hachidori's storage origin (dictionaries,
|
||||
// settings, setup state) survives userData path changes. Electron otherwise
|
||||
// derives the ID from the unpacked directory path.
|
||||
manifest.key =
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA40zTTbhCbrKnDOqj17tosDChP60VXuuMjR0tFFR/kAet21jrYn0JKPzqqNpXMGaJsx1chnuMOgaP0zuOT+gv9DNVV83wGLhQL7cC4LCoGN648WqnE2jYqBPj6hdD5A/N3uPeKFRAHkiC9HKoj2Et6sYZFTqag79QqQkkUz17X2VpjuTGmTP3lvhyEwhJWQKzOU0k6dTn6fKB2ZSqTebpw/JS2G8jf58vHlQOcfLhIiolKCG9DfMuo11aoG1s3AsFmQmw9VVyp4wgyQ80UI4lyBdqboanJxEiZ211+HKWPEgtG6VML1+Uo59qWS3uBU1apxE1+7E51UBEHaTsxajFIQIDAQAB';
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
|
||||
fs.copyFileSync(path.join(source, file), path.join(output, file));
|
||||
}
|
||||
process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`);
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
test('Hachidori staging configures Electron without changing the fork source', async () => {
|
||||
const files = ['overlay-mode.js', 'setup-state.js', 'manifest.json'];
|
||||
const source = (file: string) =>
|
||||
new URL(`../vendor/hachidori/extension/${file}`, import.meta.url);
|
||||
const before = files.map((file) => readFileSync(source(file), 'utf8'));
|
||||
|
||||
execFileSync(process.execPath, [
|
||||
fileURLToPath(new URL('./build-hachidori.mjs', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => readFileSync(source(file), 'utf8')),
|
||||
before,
|
||||
);
|
||||
const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href);
|
||||
assert.equal(staged.OVERLAY_MODE, true);
|
||||
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
||||
const original = await import(source('overlay-mode.js').href);
|
||||
assert.equal(original.OVERLAY_MODE, false);
|
||||
assert.equal(original.HOST_CAPABILITIES.customJavaScript, true);
|
||||
// SubMiner mines media from mpv, not the overlay viewport, so the lookup
|
||||
// highlight stays on for a first install.
|
||||
const stagedSetup = await import(
|
||||
new URL('../build/hachidori/setup-state.js', import.meta.url).href
|
||||
);
|
||||
assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true);
|
||||
const originalSetup = await import(source('setup-state.js').href);
|
||||
assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
||||
assert.deepEqual(manifest, {
|
||||
...originalManifest,
|
||||
permissions: originalManifest.permissions.filter(
|
||||
(permission: string) => permission !== 'userScripts',
|
||||
),
|
||||
key: manifest.key,
|
||||
});
|
||||
// The ID keys Hachidori's stored dictionaries and settings; changing the key orphans them.
|
||||
const digest = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex');
|
||||
const extensionId = [...digest.slice(0, 32)]
|
||||
.map((digit) => String.fromCharCode(97 + parseInt(digit, 16)))
|
||||
.join('');
|
||||
assert.equal(extensionId, 'jpfgmfknblendgepdejlfhocdcnjjelj');
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Exercises the real entry point with disposable Linux config and data directories.
|
||||
const { app, BrowserWindow, session, shell } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error('This app-entry smoke requires Linux XDG isolation.');
|
||||
const root = process.cwd();
|
||||
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
|
||||
const externalYomitanProfile =
|
||||
backend === 'hachidori' && !process.argv.includes('--bundled-yomitan');
|
||||
const profile = process.env.SUBMINER_DICTIONARY_SMOKE_DATA;
|
||||
assert(
|
||||
profile && fs.existsSync(profile),
|
||||
'Use bun run test:dictionary:electron for profile isolation',
|
||||
);
|
||||
process.env.XDG_CONFIG_HOME = profile;
|
||||
process.env.XDG_DATA_HOME = path.join(profile, 'data');
|
||||
fs.mkdirSync(path.join(profile, 'SubMiner'));
|
||||
fs.writeFileSync(
|
||||
path.join(profile, 'SubMiner', 'config.json'),
|
||||
JSON.stringify({
|
||||
dictionaryBackend: backend,
|
||||
// External Yomitan profiles must not block Hachidori settings.
|
||||
yomitan: {
|
||||
externalProfilePath: externalYomitanProfile ? path.join(profile, 'external-yomitan') : '',
|
||||
},
|
||||
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
|
||||
ankiConnect: { enabled: false },
|
||||
startupWarmups: { lowPowerMode: true },
|
||||
discordPresence: { enabled: false },
|
||||
updates: { enabled: false },
|
||||
}),
|
||||
);
|
||||
const openedLinks = [];
|
||||
shell.openExternal = async (url) => {
|
||||
openedLinks.push(url);
|
||||
};
|
||||
app.setAppPath(root);
|
||||
app.getVersion = () => require(path.join(root, 'package.json')).version;
|
||||
process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log');
|
||||
app.commandLine.appendSwitch('ozone-platform', 'x11');
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug'];
|
||||
require(path.join(root, 'dist/main-entry.js'));
|
||||
const deadline = setTimeout(() => {
|
||||
console.error(
|
||||
'FAIL timeout',
|
||||
BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
|
||||
);
|
||||
finish(1);
|
||||
}, 60000);
|
||||
function finish(exitCode) {
|
||||
clearTimeout(deadline);
|
||||
app.exit(exitCode);
|
||||
}
|
||||
(async () => {
|
||||
await app.whenReady();
|
||||
let window;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
window = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Hachidori') && w.isVisible(),
|
||||
);
|
||||
if (window) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(window, 'Hachidori settings window opens from actual app flag');
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
assert.match(window.webContents.getURL(), /settings.html/);
|
||||
const status = await window.webContents.executeJavaScript(
|
||||
`chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`,
|
||||
);
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.ready, true);
|
||||
|
||||
console.log(
|
||||
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
|
||||
);
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
|
||||
if (externalYomitanProfile) {
|
||||
const blockedMessage =
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage)) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(
|
||||
fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage),
|
||||
'--yomitan is rejected when an external Yomitan profile is configured',
|
||||
);
|
||||
assert.equal(session.defaultSession.extensions.getAllExtensions().length, 0);
|
||||
assert.ok(!BrowserWindow.getAllWindows().some((w) => w.getTitle().includes('Yomitan')));
|
||||
console.log('PASS external Yomitan settings blocked while Hachidori settings remain available');
|
||||
} else {
|
||||
let yomi;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
yomi = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
|
||||
);
|
||||
if (yomi) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(yomi, 'inactive --yomitan settings opens');
|
||||
assert.equal(yomi.webContents.session, session.defaultSession);
|
||||
}
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
|
||||
let overlay;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
overlay = BrowserWindow.getAllWindows().find((w) =>
|
||||
w.webContents.getURL().includes('/renderer/index.html?'),
|
||||
);
|
||||
if (overlay && !overlay.webContents.isLoading()) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(overlay, 'actual overlay initialized');
|
||||
assert.equal(
|
||||
overlay.webContents.session,
|
||||
backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession,
|
||||
);
|
||||
const requestLink = (url) =>
|
||||
overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000);
|
||||
window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true});
|
||||
window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}}));
|
||||
})`);
|
||||
assert.equal((await requestLink('file:///tmp/private')).ok, false);
|
||||
assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori');
|
||||
assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []);
|
||||
console.log(
|
||||
`PASS ${backend} overlay session, independent settings windows, real preload external link bridge`,
|
||||
);
|
||||
|
||||
finish(0);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
finish(1);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Run after bun run build. This uses a temporary profile and small local ZIPs.
|
||||
const { app, BrowserWindow, protocol, session } = require('electron');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
||||
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
bypassCSP: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
app.setPath('userData', profile);
|
||||
app.setAppPath(root);
|
||||
app.disableHardwareAcceleration();
|
||||
app.on('window-all-closed', () => {});
|
||||
const deadline = setTimeout(() => {
|
||||
console.error('Hachidori parser verification timed out', profile);
|
||||
app.exit(1);
|
||||
}, 120_000);
|
||||
|
||||
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
|
||||
const zipPath = path.join(profile, name + '.zip');
|
||||
writeStoredZip(zipPath, [
|
||||
{
|
||||
name: 'index.json',
|
||||
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
|
||||
},
|
||||
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
|
||||
]);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
const targetSession = session.fromPartition('persist:hachidori-check');
|
||||
const extension = await targetSession.extensions.loadExtension(
|
||||
path.join(root, 'build/hachidori'),
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
// Keep a host window alive while the dictionary importer opens/closes its
|
||||
// temporary settings windows, matching the running app's window lifecycle.
|
||||
const host = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
|
||||
let parserWindow = null;
|
||||
let readyPromise = null;
|
||||
let initPromise = null;
|
||||
const deps = {
|
||||
getYomitanExt: () => extension,
|
||||
getYomitanSession: () => targetSession,
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
setYomitanParserWindow: (value) => {
|
||||
parserWindow = value;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => readyPromise,
|
||||
setYomitanParserReadyPromise: (value) => {
|
||||
readyPromise = value;
|
||||
},
|
||||
getYomitanParserInitPromise: () => initPromise,
|
||||
setYomitanParserInitPromise: (value) => {
|
||||
initPromise = value;
|
||||
},
|
||||
};
|
||||
const errors = [];
|
||||
const logger = {
|
||||
error: (...args) => {
|
||||
errors.push(args);
|
||||
console.error(...args);
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Exercise discovery and revisioned options writes in the real extension,
|
||||
// without creating a note or changing the user's Anki collection.
|
||||
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
|
||||
const metadataServer = http.createServer((request, response) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
request.on('end', () => {
|
||||
const message = JSON.parse(body);
|
||||
assert.equal(message.action, 'multi');
|
||||
const result = message.params.actions.map(({ action }) => ({
|
||||
result:
|
||||
action === 'deckNames'
|
||||
? ['Test Mining']
|
||||
: action === 'modelNames'
|
||||
? ['Custom Japanese']
|
||||
: fields,
|
||||
error: null,
|
||||
}));
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ result, error: null }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const url = `http://127.0.0.1:${metadataServer.address().port}`;
|
||||
const ankiConfig = {
|
||||
tags: ['SubMiner', 'Autofill'],
|
||||
fields: {
|
||||
word: 'Term',
|
||||
sentence: 'Context',
|
||||
wordAudio: 'Pronunciation',
|
||||
image: 'Image',
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
ankiConfig,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const anki = await parserWindow.webContents.executeJavaScript(
|
||||
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
|
||||
);
|
||||
assert.equal(anki.model, 'Custom Japanese');
|
||||
assert.equal(anki.deck, 'Test Mining');
|
||||
assert.deepEqual(anki.tags, ankiConfig.tags);
|
||||
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
|
||||
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
|
||||
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
|
||||
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
|
||||
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
|
||||
} finally {
|
||||
await new Promise((resolve) => metadataServer.close(resolve));
|
||||
}
|
||||
const archives = [
|
||||
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
|
||||
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
|
||||
]),
|
||||
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
|
||||
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
|
||||
]),
|
||||
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
|
||||
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
|
||||
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
|
||||
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
|
||||
['頻度だけ', 'freq', 17],
|
||||
]),
|
||||
fixture(
|
||||
'occurrences',
|
||||
'SubMiner Test Occurrences',
|
||||
'term_meta_bank_1.json',
|
||||
[['頻度だけ', 'freq', 9000]],
|
||||
'occurrence-based',
|
||||
),
|
||||
];
|
||||
for (const archive of archives)
|
||||
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
|
||||
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
|
||||
assert.equal(dictionaries.length, 4);
|
||||
parser.clearYomitanParserCachesForWindow(parserWindow);
|
||||
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
});
|
||||
assert.equal(tokens[0].isNameMatch, true);
|
||||
assert.equal(tokens[1].headword, '食べる');
|
||||
assert.equal(tokens[1].reading, 'たべた');
|
||||
assert.equal(tokens[1].startPos, 4);
|
||||
assert.equal(tokens[1].endPos, 7);
|
||||
assert.equal(tokens[1].frequencyRank, 42);
|
||||
assert.deepEqual(tokens[1].wordClasses, ['v1']);
|
||||
const exact = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'たべる' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.equal(exact.length, 1);
|
||||
assert.equal(exact[0].frequency, 42);
|
||||
assert.equal(exact[0].reading, 'たべる');
|
||||
assert.equal(exact[0].hasReading, false);
|
||||
const otherReading = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'べつのよみ' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
// The shared frequency pipeline retries a missing reading as a term-only query.
|
||||
assert.equal(otherReading[0]?.frequency, 42);
|
||||
const unmatched = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '頻度だけ', reading: null }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.deepEqual(unmatched, []);
|
||||
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
||||
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
||||
true,
|
||||
);
|
||||
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
|
||||
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
|
||||
const proxyState = await host.webContents.executeJavaScript(
|
||||
"chrome.storage.local.get('subminerAnkiProxyUrl')",
|
||||
);
|
||||
assert.equal(proxyState.subminerAnkiProxyUrl, null);
|
||||
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
|
||||
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
|
||||
}
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(errors.length, 0);
|
||||
console.log(
|
||||
'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
|
||||
);
|
||||
clearTimeout(deadline);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(deadline);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import '../vendor/hachidori/extension/reader-options.js';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
|
||||
|
||||
async function mine({ proxy = true, audioFails = false } = {}) {
|
||||
const events: string[] = [];
|
||||
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
|
||||
let fields: Record<string, string> = {};
|
||||
let initialAudio = '';
|
||||
let stored = false;
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
const { action, params } = JSON.parse(String(options.body));
|
||||
events.push(action);
|
||||
let result: unknown;
|
||||
switch (action) {
|
||||
case 'multi':
|
||||
result = [
|
||||
{ result: ['Default'], error: null },
|
||||
{ result: ['Basic'], error: null },
|
||||
{ result: ['Expression', 'ExpressionAudio'], error: null },
|
||||
];
|
||||
break;
|
||||
case 'canAddNotes':
|
||||
result = [true];
|
||||
break;
|
||||
case 'canAddNotesWithErrorDetail':
|
||||
result = [{ canAdd: true, error: null }];
|
||||
break;
|
||||
case 'getMediaFilesNames':
|
||||
result = stored ? [filename] : [];
|
||||
break;
|
||||
case 'storeMediaFile':
|
||||
stored = true;
|
||||
result = filename;
|
||||
break;
|
||||
case 'addNote':
|
||||
fields = { ...params.note.fields };
|
||||
initialAudio = fields.ExpressionAudio ?? '';
|
||||
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
|
||||
result = 123;
|
||||
break;
|
||||
case 'notesInfo':
|
||||
result = [
|
||||
{
|
||||
noteId: 123,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, { value }]),
|
||||
),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'updateNoteFields':
|
||||
Object.assign(fields, params.note.fields);
|
||||
result = null;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected action: ${action}`);
|
||||
}
|
||||
return Response.json({ result, error: null });
|
||||
},
|
||||
});
|
||||
const service = createAnkiWorkerService({
|
||||
gateway,
|
||||
readOptions: async () => ({
|
||||
anki: {
|
||||
url: 'http://127.0.0.1:8766',
|
||||
apiKey: '',
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
deck: 'Default',
|
||||
model: 'Basic',
|
||||
tags: [],
|
||||
fields: {},
|
||||
duplicateScope: 'model',
|
||||
duplicateBehavior: 'prevent',
|
||||
captureScreenshot: false,
|
||||
fieldTemplates: {
|
||||
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
|
||||
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
audioSources: [
|
||||
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
|
||||
],
|
||||
experimental: {},
|
||||
}),
|
||||
readDictionaries: async () => [],
|
||||
engine: async () => ({ ready: true, loading: false, generation: 1 }),
|
||||
offscreen: async (message: { type: string; audio?: string }) => {
|
||||
if (message.type === 'hd_anki_audio') {
|
||||
events.push('pronunciation');
|
||||
if (audioFails) throw new Error('No pronunciation available');
|
||||
return { filename, data: 'YXVkaW8=' };
|
||||
}
|
||||
return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] };
|
||||
},
|
||||
duplicateIndex: { source: async () => null, recordWrite: async () => {} },
|
||||
});
|
||||
const status = await service.status();
|
||||
assert.equal(status.available, true, status.error);
|
||||
const result = await service.submit({
|
||||
configKey: status.configKey,
|
||||
generation: 1,
|
||||
term: { expression: '猫', reading: 'ねこ' },
|
||||
});
|
||||
return { result, initialAudio, fields, events, filename };
|
||||
}
|
||||
|
||||
test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => {
|
||||
const value = await mine();
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.equal(value.initialAudio, `[sound:${value.filename}]`);
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
});
|
||||
|
||||
test('direct Hachidori keeps deferred pronunciation', async () => {
|
||||
const value = await mine({ proxy: false });
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`);
|
||||
});
|
||||
|
||||
test('unavailable pronunciation remains a warning without a late audio write', async () => {
|
||||
const value = await mine({ audioFails: true });
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.match(value.result.warnings.join(' '), /No pronunciation available/);
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
assert.equal(value.events.includes('updateNoteFields'), false);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
|
||||
const bridge = readFileSync(
|
||||
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function run(code: string) {
|
||||
runInNewContext(`${bridge}\n${code}`, {
|
||||
window: new EventTarget(),
|
||||
EventTarget,
|
||||
CustomEvent,
|
||||
assert,
|
||||
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
|
||||
KeyboardEvent: class {
|
||||
constructor(_type: string, options: KeyboardEventInit = {}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Hachidori marks popup state independently from successful lookups', () => {
|
||||
run(`
|
||||
const events = [];
|
||||
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
|
||||
window.addEventListener(name, () => events.push(name));
|
||||
}
|
||||
const attributes = new Map();
|
||||
const host = { setAttribute: (name, value) => attributes.set(name, value) };
|
||||
SubMinerHachidori.markHost(host, true);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
|
||||
assert.equal(events.join(','), '');
|
||||
SubMinerHachidori.lookup();
|
||||
SubMinerHachidori.markHost(host, false);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
|
||||
assert.equal(events.join(','), 'subminer-yomitan-lookup');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
|
||||
run(`
|
||||
const result = (id, matched, dictionary, options = {}) => ({
|
||||
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
|
||||
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
|
||||
});
|
||||
const character = characterPrefix + ' - Current show';
|
||||
const longer = result('longer', '花子さん', 'General');
|
||||
const normal = result('normal', '花子', 'General');
|
||||
const person = result('person', '花子', character);
|
||||
const shorter = result('shorter', '花', character);
|
||||
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
|
||||
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
|
||||
const merged = result('merged', '花子', 'General');
|
||||
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
|
||||
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
|
||||
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
|
||||
assert.equal(merged.term.glossaries[0].dictionary, 'General');
|
||||
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
|
||||
const transformed = { ...person, ...change };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
|
||||
}
|
||||
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
|
||||
const aliased = result('aliased', '花子', 'Imported character data');
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
|
||||
const requests: Array<{ url: string; body: string }> = [];
|
||||
const proxy = 'http://127.0.0.1:8766';
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => proxy,
|
||||
fetch: async (url: string, options: RequestInit) => {
|
||||
assert.equal(typeof options.body, 'string');
|
||||
requests.push({ url, body: String(options.body) });
|
||||
return Response.json({ result: 123, error: null });
|
||||
},
|
||||
});
|
||||
const params = {
|
||||
note: { fields: { Expression: '花子' } },
|
||||
subminerDuplicateNoteIds: [456],
|
||||
subminerEnrich: true,
|
||||
};
|
||||
await gateway.invoke('addNote', params, '', 1000, proxy);
|
||||
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
|
||||
assert.deepEqual(
|
||||
requests.map((request) => JSON.parse(request.body).params),
|
||||
[params, { note: params.note }],
|
||||
);
|
||||
assert.equal(params.subminerEnrich, true);
|
||||
const direct = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => null,
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
|
||||
return Response.json({ result: null, error: null });
|
||||
},
|
||||
});
|
||||
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
|
||||
});
|
||||
|
||||
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
|
||||
run(`
|
||||
const calls = [];
|
||||
const disconnect = SubMinerHachidori.connect({
|
||||
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
|
||||
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
|
||||
scroll: (x, y) => calls.push(x + ':' + y),
|
||||
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
|
||||
});
|
||||
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
|
||||
send(null);
|
||||
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
|
||||
send({ type: 'mineSelected' });
|
||||
send({ type: 'playCurrentAudio' });
|
||||
send({ type: 'scanSelectedText' });
|
||||
send({ type: 'cycleAudioSource', direction: -1 });
|
||||
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
|
||||
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
|
||||
send({ type: 'setVisible', visible: false });
|
||||
send({ type: 'clearActiveTextSource' });
|
||||
disconnect();
|
||||
send({ type: 'mineSelected' });
|
||||
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
|
||||
`);
|
||||
});
|
||||
@@ -26,6 +26,14 @@ const REQUIRED_APP_FILES = [
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'hachidori/manifest.json',
|
||||
'hachidori/settings.html',
|
||||
'hachidori/subminer-host.js',
|
||||
'hachidori/vendor/hoshidicts.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded-idbfs.wasm',
|
||||
'hachidori/LICENSE',
|
||||
'hachidori/SOURCE.json',
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachi-settings-'));
|
||||
const env = { ...process.env, SUBMINER_DICTIONARY_SMOKE_DATA: profile };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[
|
||||
'--no-sandbox',
|
||||
fileURLToPath(new URL('./check-dictionary-backends.cjs', import.meta.url)),
|
||||
...process.argv.slice(2),
|
||||
],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
+45
-49
@@ -22,39 +22,6 @@ const timeout = setTimeout(() => {
|
||||
app.exit(1);
|
||||
}, 60_000);
|
||||
|
||||
const STATIC_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
// The stats dashboard is served by the stats HTTP server in the app, so load it
|
||||
// over loopback HTTP from the packaged stats/dist and treat missing static
|
||||
// assets as failures. API routes are not part of this smoke and may 404.
|
||||
function serveStatsDist(root, failedRequests) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
|
||||
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(root, relative));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
|
||||
async function smoke() {
|
||||
await app.whenReady();
|
||||
const packagedRequire = createRequire(path.join(archive, 'package.json'));
|
||||
@@ -83,6 +50,10 @@ async function smoke() {
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const hachidori = await session
|
||||
.fromPartition('persist:hachidori')
|
||||
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
|
||||
assert(hachidori.id, 'Hachidori extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred(
|
||||
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
|
||||
@@ -93,9 +64,7 @@ async function smoke() {
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
},
|
||||
);
|
||||
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
|
||||
await once(statsServer, 'listening');
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -104,25 +73,52 @@ async function smoke() {
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (ui === 'stats') {
|
||||
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
|
||||
// Let in-flight font requests settle before the window goes away.
|
||||
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||
} else {
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
}
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
} finally {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
statsServer.close();
|
||||
// The stats dashboard uses HTTP for both assets and API requests in the app.
|
||||
const { ImmersionTrackerService } = packagedRequire(
|
||||
'./dist/core/services/immersion-tracker-service.js',
|
||||
);
|
||||
const { createStatsApp, startNodeHttpServer } = packagedRequire(
|
||||
'./dist/core/services/stats-server.js',
|
||||
);
|
||||
const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') });
|
||||
const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker };
|
||||
let statsHttp;
|
||||
const statsServer = await startNodeHttpServer(
|
||||
createStatsApp(tracker, statsConfig),
|
||||
statsConfig,
|
||||
(listener) => (statsHttp = http.createServer(listener)),
|
||||
);
|
||||
const statsWindow = new BrowserWindow({ show: false });
|
||||
try {
|
||||
const url = `http://127.0.0.1:${statsHttp.address().port}`;
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => {
|
||||
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
|
||||
});
|
||||
await statsWindow.loadURL(url);
|
||||
// Let in-flight font requests settle before the window goes away.
|
||||
await statsWindow.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||
for (const endpoint of ['overview', 'sessions']) {
|
||||
const response = await fetch(`${url}/api/stats/${endpoint}`);
|
||||
assert.equal(response.status, 200, `Stats ${endpoint} request failed`);
|
||||
await response.json();
|
||||
}
|
||||
} finally {
|
||||
statsWindow.destroy();
|
||||
await statsServer.close();
|
||||
tracker.destroy();
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1807,6 +1807,9 @@ export class AnkiIntegration {
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
log.debug(
|
||||
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
|
||||
);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
|
||||
@@ -441,6 +441,127 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
|
||||
const received: unknown[] = [];
|
||||
let upstreamError: string | null = null;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: null, error: upstreamError }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
added.push(count);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const [id, marked, error] of [
|
||||
[51, true, null],
|
||||
[52, false, null],
|
||||
[53, true, 'failed'],
|
||||
] satisfies Array<[number, boolean, string | null]>) {
|
||||
upstreamError = error;
|
||||
await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'updateNoteFields',
|
||||
version: 6,
|
||||
params: {
|
||||
note: { id, fields: { Expression: '猫' } },
|
||||
...(marked ? { subminerEnrich: true } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
await waitForCondition(() => processed.length > 0);
|
||||
assert.deepEqual(processed, [51]);
|
||||
assert.deepEqual(added, []);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('stats-owned notes bypass overlay enrichment while popup notes still enqueue', async () => {
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const received: unknown[] = [];
|
||||
let noteId = 70;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.end(JSON.stringify({ result: ++noteId, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (_count, ids) => {
|
||||
added.push(...ids);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const metadata of [{ subminerEnrich: false }, {}]) {
|
||||
const response: Response = await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: { note: { fields: { Expression: '猫' } }, ...metadata },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
await response.json();
|
||||
}
|
||||
await waitForCondition(() => processed.includes(72));
|
||||
assert.deepEqual(processed, [72]);
|
||||
assert.deepEqual(added, [71, 72]);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('subminerEnrich' in params) ||
|
||||
params.subminerEnrich !== true
|
||||
)
|
||||
return;
|
||||
const note = 'note' in params ? params.note : null;
|
||||
if (!note || typeof note !== 'object' || !('id' in note)) return;
|
||||
const response = this.tryParseJsonValue(responseBody);
|
||||
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!('error' in response) ||
|
||||
response.error !== null ||
|
||||
!('result' in response) ||
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||
return;
|
||||
}
|
||||
@@ -239,12 +264,23 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
: this.collectNoteIdsForAction(action, responseResult);
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
action === 'addNote' &&
|
||||
params &&
|
||||
typeof params === 'object' &&
|
||||
'subminerEnrich' in params &&
|
||||
params.subminerEnrich === false
|
||||
) {
|
||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
return;
|
||||
}
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
return;
|
||||
@@ -293,7 +329,7 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
if (action !== 'addNote' && action !== 'updateNoteFields') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
@@ -301,12 +337,17 @@ export class AnkiConnectProxyServer {
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
if (
|
||||
!params ||
|
||||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
|
||||
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
|
||||
) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
delete nextParams.subminerEnrich;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
@@ -455,7 +496,7 @@ export class AnkiConnectProxyServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
private enqueueNotes(noteIds: number[], recordAdded = true): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
@@ -472,7 +513,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
@@ -110,6 +111,27 @@ function createWorkflowHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow preserves stats cards discovered by polling', async () => {
|
||||
const { workflow, deps, updates } = createWorkflowHarness();
|
||||
const note = {
|
||||
noteId: 42,
|
||||
tags: [STATS_MINING_TAG],
|
||||
fields: { Expression: { value: '猫' }, Sentence: { value: '猫がいる。' } },
|
||||
};
|
||||
deps.client.notesInfo = async () => [note];
|
||||
deps.captureSubtitleMediaContext = () => assert.fail('Must not capture current playback');
|
||||
deps.findDuplicateNote = async () => assert.fail('Must not regroup a stats card');
|
||||
let cachedNote: NoteUpdateWorkflowNoteInfo | undefined;
|
||||
deps.appendKnownWordsFromNoteInfo = (value) => {
|
||||
cachedNote = value;
|
||||
};
|
||||
|
||||
await workflow.execute(42);
|
||||
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(cachedNote, note);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import type {
|
||||
WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
tags?: string[];
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
@@ -185,6 +187,10 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
if (noteInfo.tags?.includes(STATS_MINING_TAG)) {
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
return;
|
||||
}
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -476,3 +477,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
|
||||
const args = parseArgs(['--hachidori']);
|
||||
assert.equal(args.hachidori, true);
|
||||
assert.equal(args.yomitan, false);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
|
||||
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
|
||||
assert.equal(
|
||||
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
|
||||
});
|
||||
+11
-1
@@ -12,6 +12,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
youtubeBrowser: boolean;
|
||||
@@ -133,6 +134,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -279,6 +281,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--hachidori') args.hachidori = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--youtube-browser') args.youtubeBrowser = true;
|
||||
@@ -563,6 +566,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -643,6 +647,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.youtubeBrowser &&
|
||||
@@ -716,6 +721,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -767,8 +773,12 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
}
|
||||
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
|
||||
}
|
||||
|
||||
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
args.yomitan &&
|
||||
(args.yomitan || args.hachidori) &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
|
||||
@@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--yomitan Open Yomitan settings window
|
||||
--hachidori Open Hachidori settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -22,6 +22,8 @@ export type {
|
||||
} from './definitions/shared';
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -57,6 +59,8 @@ const { stats } = STATS_DEFAULT_CONFIG;
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'hachidori'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -18,6 +20,8 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
hachidori: { externalHostManagementUrl: '' },
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
|
||||
@@ -81,6 +81,20 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'hachidori.externalHostManagementUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
|
||||
description:
|
||||
'Management URL override for character dictionary uploads to a linked Hachidori Docker host. Empty uses the linked host on the Docker default management port.',
|
||||
},
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
enumValues: ['yomitan', 'hachidori'],
|
||||
defaultValue: defaultConfig.dictionaryBackend,
|
||||
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
|
||||
},
|
||||
{
|
||||
path: 'logging.level',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Dictionary Backend',
|
||||
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
|
||||
notes: [
|
||||
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Hachidori External Dictionary Imports',
|
||||
description: [
|
||||
'Override the linked Docker host management URL only for a non-default port or a reverse proxy.',
|
||||
],
|
||||
notes: ['Used only while Hachidori is linked to an external host.'],
|
||||
key: 'hachidori',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
|
||||
@@ -2,10 +2,37 @@ import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
import { parseHachidoriManagementUrl } from '../../shared/hachidori-sharing';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.hachidori) && src.hachidori.externalHostManagementUrl !== undefined) {
|
||||
try {
|
||||
resolved.hachidori.externalHostManagementUrl = parseHachidoriManagementUrl(
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
);
|
||||
} catch {
|
||||
warn(
|
||||
'hachidori.externalHostManagementUrl',
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
resolved.hachidori.externalHostManagementUrl,
|
||||
'Expected an HTTP(S) origin or an empty string.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
|
||||
resolved.dictionaryBackend = src.dictionaryBackend;
|
||||
} else if (src.dictionaryBackend !== undefined) {
|
||||
warn(
|
||||
'dictionaryBackend',
|
||||
src.dictionaryBackend,
|
||||
resolved.dictionaryBackend,
|
||||
"Expected 'yomitan' or 'hachidori'.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.subtitleSelection)) {
|
||||
const enabled = asBoolean(src.subtitleSelection.enabled);
|
||||
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { buildConfigSettingsRegistry } from '../settings/registry';
|
||||
import { createResolveContext } from './context';
|
||||
import { applyCoreDomainConfig } from './core-domains';
|
||||
|
||||
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
|
||||
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
|
||||
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
|
||||
assert.equal(resolved.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(warnings, []);
|
||||
const field = buildConfigSettingsRegistry(resolved).find(
|
||||
(entry) => entry.configPath === 'dictionaryBackend',
|
||||
);
|
||||
assert.equal(field?.restartBehavior, 'restart');
|
||||
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
|
||||
assert.equal(field?.category, 'integrations');
|
||||
});
|
||||
|
||||
test('unknown dictionary backend values warn and preserve the default', () => {
|
||||
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.dictionaryBackend = dictionaryBackend;
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0]?.path, 'dictionaryBackend');
|
||||
}
|
||||
});
|
||||
|
||||
test('Hachidori external import URL accepts HTTP origins and rejects invalid targets', () => {
|
||||
assert.equal(resolveConfig({}).resolved.hachidori.externalHostManagementUrl, '');
|
||||
const result = resolveConfig({
|
||||
hachidori: { externalHostManagementUrl: 'http://127.0.0.1:8780/' },
|
||||
});
|
||||
assert.equal(result.resolved.hachidori.externalHostManagementUrl, 'http://127.0.0.1:8780');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
for (const value of [
|
||||
'file:///tmp/dict',
|
||||
'http://host/import',
|
||||
'http://user:password@host',
|
||||
true,
|
||||
]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.hachidori = { externalHostManagementUrl: value };
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.hachidori.externalHostManagementUrl, '');
|
||||
assert.equal(warnings[0]?.path, 'hachidori.externalHostManagementUrl');
|
||||
}
|
||||
});
|
||||
@@ -146,6 +146,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Dictionary Lookup',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
@@ -228,8 +229,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
@@ -270,7 +271,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup':
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
@@ -334,6 +335,9 @@ function humanizePath(path: string): string {
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (path === 'dictionaryBackend' || path.startsWith('hachidori.')) {
|
||||
return { category: 'integrations', section: 'Dictionary Lookup' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
|
||||
@@ -2525,6 +2525,8 @@ Aligned English subtitle
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
|
||||
const addRequest = requests.find((request) => request.action === 'addNote');
|
||||
assert.deepEqual(addRequest?.params?.note?.tags, ['SubMiner', 'SubMiner::Stats']);
|
||||
mediaRelease.audio?.();
|
||||
mediaRelease.image?.();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
||||
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
|
||||
import { Database } from '../immersion-tracker/sqlite.js';
|
||||
import {
|
||||
applyPragmas,
|
||||
ensureSchema,
|
||||
getOrCreateAnimeRecord,
|
||||
getOrCreateVideoRecord,
|
||||
@@ -50,6 +51,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
|
||||
async function backfillMissingCoverBlob(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-test.mkv', {
|
||||
canonicalTitle: 'Cover Fetcher Test',
|
||||
@@ -114,6 +116,7 @@ test(
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
|
||||
canonicalTitle: 'Shared Cover Show',
|
||||
@@ -203,6 +206,7 @@ function createJsonResponse(payload: unknown): Response {
|
||||
test('fetchIfMissing uses guessit primary title and season when available', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
|
||||
canonicalTitle:
|
||||
@@ -308,6 +312,7 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
||||
test('fetchIfMissing falls back to internal parser when guessit throws', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-fallback-test.mkv', {
|
||||
canonicalTitle: 'School Vlog S01E01',
|
||||
@@ -372,6 +377,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
|
||||
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
|
||||
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
|
||||
@@ -450,6 +456,7 @@ test('fetchIfMissing caches a no-match when the season cannot be resolved', asyn
|
||||
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
|
||||
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
|
||||
@@ -557,6 +564,7 @@ for (const linkedToAnilist of [false, true]) {
|
||||
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
|
||||
@@ -674,6 +682,7 @@ for (const linkedToAnilist of [false, true]) {
|
||||
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
applyPragmas(db);
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
|
||||
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -133,6 +134,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -656,6 +660,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
@@ -1119,3 +1124,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
|
||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
for (const source of ['initial', 'second-instance'] as const) {
|
||||
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
|
||||
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
assert.equal(calls.includes('initializeOverlayRuntime'), false);
|
||||
assert.equal(calls.includes('connectMpvClient'), false);
|
||||
});
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openHachidoriSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -167,6 +168,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
openYoutubeBrowserWindow: () => void;
|
||||
@@ -273,6 +275,11 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openHachidoriSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow,
|
||||
@@ -424,6 +431,8 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.yomitan) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.hachidori) {
|
||||
deps.openHachidoriSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveHachidoriExtensionPath } from './hachidori-extension';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
|
||||
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
|
||||
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
||||
}),
|
||||
'/app/build/hachidori',
|
||||
);
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/resources/hachidori/manifest.json',
|
||||
}),
|
||||
'/resources/hachidori',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
|
||||
/build:hachidori/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
|
||||
try {
|
||||
const source = path.join(root, 'source');
|
||||
const profile = path.join(root, 'profile');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
|
||||
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
|
||||
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
|
||||
typeof ensureExtensionCopyAsync
|
||||
>[2];
|
||||
const copy = await ensureExtensionCopyAsync(source, profile, options);
|
||||
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
|
||||
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import electron from 'electron';
|
||||
import type { Extension, Session } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
|
||||
|
||||
export function getHachidoriSession(): Session {
|
||||
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
|
||||
}
|
||||
|
||||
export function resolveHachidoriExtensionPath(options: {
|
||||
moduleDir: string;
|
||||
resourcesPath: string;
|
||||
exists?: (candidate: string) => boolean;
|
||||
}): string {
|
||||
const candidates = [
|
||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
||||
path.join(options.resourcesPath, 'hachidori'),
|
||||
];
|
||||
const found = candidates.find((candidate) =>
|
||||
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
|
||||
);
|
||||
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
|
||||
export function createHachidoriExtensionRuntime(userDataPath: string) {
|
||||
let extension: Extension | null = null;
|
||||
let loading: Promise<Extension> | null = null;
|
||||
return {
|
||||
getSession: getHachidoriSession,
|
||||
ensureLoaded(): Promise<Extension> {
|
||||
if (extension) return Promise.resolve(extension);
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
const source = resolveHachidoriExtensionPath({
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
|
||||
extensionName: 'hachidori',
|
||||
});
|
||||
const session = getHachidoriSession();
|
||||
// Electron can reuse an old extension worker after its files change.
|
||||
// Drop worker registrations before loading, preserving dictionaries and settings.
|
||||
await session.clearStorageData({ storages: ['serviceworkers'] });
|
||||
extension = await session.extensions.loadExtension(copy.targetDir, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
return extension;
|
||||
})().finally(() => {
|
||||
loading = null;
|
||||
});
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from '../storage.js';
|
||||
import { ensureSchema } from '../storage.js';
|
||||
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
|
||||
import { pruneRawRetention } from '../maintenance.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
@@ -31,13 +30,7 @@ function cleanupDbPath(dbPath: string): void {
|
||||
*/
|
||||
function seed(
|
||||
db: DatabaseSync,
|
||||
lines: Array<{
|
||||
session: 1 | 2;
|
||||
wordId: number;
|
||||
dayOffset: number;
|
||||
msOffset?: number;
|
||||
count?: number;
|
||||
}>,
|
||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
||||
options: { legacyRows?: boolean } = {},
|
||||
): void {
|
||||
db.exec(`
|
||||
@@ -72,7 +65,7 @@ function seed(
|
||||
let lineId = 0;
|
||||
for (const line of lines) {
|
||||
lineId += 1;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS + (line.msOffset ?? 0);
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||
if (options.legacyRows) {
|
||||
@@ -90,13 +83,13 @@ function seed(
|
||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||
),
|
||||
first_seen = (
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000.0
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
),
|
||||
last_seen = (
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000.0
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
@@ -207,167 +200,6 @@ test('deleting the latest session moves last_seen back to the surviving line', (
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
test(`deleting the latest session keeps fractional seen timestamps exact (legacyRows=${legacyRows})`, () => {
|
||||
// Stored seen values are fractional seconds; whole-second rounding used to miss
|
||||
// the removed extreme (or truncate the refreshed one).
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 22, dayOffset: 0, msOffset: 251 },
|
||||
{ session: 2, wordId: 22, dayOffset: 5, msOffset: 882 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
deleteSession(db, 2);
|
||||
|
||||
const word = readWord(db, 22);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(word?.firstSeen, (BASE_MS + 251) / 1000);
|
||||
assert.equal(word?.lastSeen, (BASE_MS + 251) / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('deleting legacy history preserves first_seen when it predates the flush timestamp', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 23, dayOffset: 0, msOffset: 400 },
|
||||
{ session: 2, wordId: 23, dayOffset: 5, msOffset: 400 },
|
||||
]);
|
||||
|
||||
try {
|
||||
// A queue delay and retention-pruned history cannot be distinguished from
|
||||
// the old flush-time timestamp. Preserve the earlier date in either case.
|
||||
db.prepare('UPDATE imm_words SET first_seen = ? WHERE id = 23').run(BASE_MS / 1000);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 23)?.firstSeen, BASE_MS / 1000);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
for (const legacyRows of [false, true]) {
|
||||
for (const deletedSession of [2, 3]) {
|
||||
test(`deleting retained session ${deletedSession} preserves pruned first_seen (legacyRows=${legacyRows})`, () => {
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 24, dayOffset: 0 },
|
||||
{ session: 2, wordId: 24, dayOffset: 1 },
|
||||
],
|
||||
{ legacyRows },
|
||||
);
|
||||
|
||||
try {
|
||||
const latestMs = BASE_MS + 2 * DAY_MS;
|
||||
db.exec(`
|
||||
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (3, 's3', 2, '${latestMs}', '${latestMs + 1000}', 2, ${latestMs}, ${latestMs});
|
||||
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (3, 3, 2, 3, '猫', ${latestMs}, ${latestMs});
|
||||
INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (3, 24, 1, ${legacyRows ? 'NULL' : latestMs});
|
||||
UPDATE imm_words SET frequency = 3, last_seen = ${latestMs / 1000} WHERE id = 24;
|
||||
INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency)
|
||||
SELECT 24, '猫', first_seen, last_seen, frequency FROM imm_words WHERE id = 24;
|
||||
INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
SELECT line_id, 24, occurrence_count, seen_ms FROM imm_word_line_occurrences WHERE word_id = 24;
|
||||
`);
|
||||
pruneRawRetention(db, latestMs, {
|
||||
eventsRetentionMs: Infinity,
|
||||
telemetryRetentionMs: Infinity,
|
||||
sessionsRetentionMs: 1.5 * DAY_MS,
|
||||
});
|
||||
|
||||
deleteSession(db, deletedSession);
|
||||
|
||||
for (const table of ['imm_words', 'imm_kanji']) {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table} WHERE id = 24`,
|
||||
)
|
||||
.get() as { frequency: number; firstSeen: number; lastSeen: number };
|
||||
assert.equal(row.frequency, 2);
|
||||
assert.equal(row.firstSeen, BASE_MS / 1000, `${table} keeps the pruned discovery date`);
|
||||
assert.equal(row.lastSeen, (BASE_MS + (deletedSession === 2 ? 2 : 1) * DAY_MS) / 1000);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('deleting a queued subtitle uses observation time despite a delayed flush', () => {
|
||||
const { db, dbPath } = createDb([]);
|
||||
|
||||
try {
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
for (const sessionId of [1, 2]) {
|
||||
const seenMs = BASE_MS + (sessionId - 1) * DAY_MS + 400;
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId: sessionId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: '猫',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '猫',
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
partOfSpeech: 'noun',
|
||||
pos1: '名詞',
|
||||
pos2: '一般',
|
||||
pos3: '',
|
||||
occurrenceCount: 1,
|
||||
frequencyRank: null,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [{ kanji: '猫', occurrenceCount: 1 }],
|
||||
firstSeen: seenMs / 1000,
|
||||
lastSeen: seenMs / 1000,
|
||||
},
|
||||
stmts,
|
||||
);
|
||||
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
|
||||
const row = db
|
||||
.prepare(`SELECT seen_ms AS seenMs FROM ${table} WHERE line_id = ?`)
|
||||
.get(sessionId) as { seenMs: number };
|
||||
assert.equal(row.seenMs, seenMs);
|
||||
}
|
||||
}
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
for (const table of ['imm_words', 'imm_kanji']) {
|
||||
const row = db
|
||||
.prepare(`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM ${table}`)
|
||||
.get() as {
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
assert.equal(row.frequency, 1);
|
||||
assert.equal(row.firstSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||
assert.equal(row.lastSeen, (BASE_MS + DAY_MS + 400) / 1000);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||
const { db, dbPath } = createDb([
|
||||
|
||||
@@ -4567,13 +4567,13 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
||||
|
||||
assert.ok(sharedWordRow);
|
||||
assert.equal(sharedWordRow.frequency, 1);
|
||||
assert.equal(sharedWordRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedWordRow.last_seen, keptTs / 1000);
|
||||
assert.equal(sharedWordRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedWordRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(deletedOnlyWordRow ?? null, null);
|
||||
assert.ok(sharedKanjiRow);
|
||||
assert.equal(sharedKanjiRow.frequency, 1);
|
||||
assert.equal(sharedKanjiRow.first_seen, keptTs / 1000);
|
||||
assert.equal(sharedKanjiRow.last_seen, keptTs / 1000);
|
||||
assert.equal(sharedKanjiRow.first_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(sharedKanjiRow.last_seen, Math.floor(keptTs / 1000));
|
||||
assert.equal(deletedOnlyKanjiRow ?? null, null);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
} from './query-lexical';
|
||||
import { Database } from './sqlite';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { createTrackerPreparedStatements, ensureSchema, executeQueuedWrite } from './storage';
|
||||
import { BASE_MS, createDb, seedEndedSession, seedVideo } from './__tests__/lifetime-test-fixtures';
|
||||
import { ensureSchema } from './storage';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-rollups-'));
|
||||
@@ -126,121 +125,6 @@ test('lexical rollup rebuild excludes rows hidden by vocabulary persistence rule
|
||||
}
|
||||
});
|
||||
|
||||
test('re-seeing a filtered legacy word keeps charts consistent with the summary', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const firstSeen = BASE_MS / 1000;
|
||||
// Legacy row whose stored merged-token POS fails the persistence filter.
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES ('二死', 'にし', 'にし', 'other', '助詞|動詞', '格助詞|自立', '一般|*', ?, ?, 1)`,
|
||||
).run(firstSeen, firstSeen);
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
// The incoming token passes the filter, but the upsert keeps the stored POS.
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'subtitleLine',
|
||||
sessionId,
|
||||
videoId,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: null,
|
||||
segmentEndMs: null,
|
||||
text: 'にし',
|
||||
wordOccurrences: [
|
||||
{
|
||||
headword: '二死',
|
||||
word: 'にし',
|
||||
reading: 'にし',
|
||||
partOfSpeech: 'verb',
|
||||
pos1: '動詞',
|
||||
pos2: '自立',
|
||||
pos3: '*',
|
||||
frequencyRank: null,
|
||||
occurrenceCount: 1,
|
||||
},
|
||||
],
|
||||
kanjiOccurrences: [],
|
||||
firstSeen: firstSeen + 60,
|
||||
lastSeen: firstSeen + 60,
|
||||
},
|
||||
createTrackerPreparedStatements(db),
|
||||
);
|
||||
|
||||
const plotted = getVocabularyChartData(db).newWordsTimeline.reduce(
|
||||
(sum, day) => sum + day.wordCount,
|
||||
0,
|
||||
);
|
||||
assert.equal(getVocabularySummary(db, null).uniqueWords, 0);
|
||||
assert.equal(plotted, 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild repairs last_seen left pointing at deleted history', () => {
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const videoId = seedVideo(db, null, 'episode');
|
||||
const sessionId = seedEndedSession(db, videoId, BASE_MS, { activeMs: 1_000 });
|
||||
const lineId = Number(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(session_id, video_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, 1, '猫', ?, ?)`,
|
||||
)
|
||||
.run(sessionId, videoId, BASE_MS, BASE_MS).lastInsertRowid,
|
||||
);
|
||||
const seen = (BASE_MS + 251) / 1000;
|
||||
// first_seen this early may be real history pruned by retention; never touched.
|
||||
const earlyFirst = seen - 86_400.5;
|
||||
const staleLast = seen + 86_400.631;
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(id, headword, word, reading, first_seen, last_seen, frequency)
|
||||
VALUES (1, '猫', '猫', 'ねこ', ?, ?, 1), (2, '犬', '犬', 'いぬ', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast, seen - 0.1, seen - 0.1);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (1, '猫', ?, ?, 1)`,
|
||||
).run(earlyFirst, staleLast);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?), (?, 2, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251, lineId, BASE_MS + 251);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms)
|
||||
VALUES (?, 1, 1, ?)`,
|
||||
).run(lineId, BASE_MS + 251);
|
||||
|
||||
rebuildLexicalDailyRollups(db);
|
||||
|
||||
const bounds = (table: string): Array<[number, number]> =>
|
||||
(
|
||||
db
|
||||
.prepare(`SELECT first_seen AS first, last_seen AS last FROM ${table} ORDER BY id`)
|
||||
.all() as Array<{
|
||||
first: number;
|
||||
last: number;
|
||||
}>
|
||||
).map((row) => [row.first, row.last]);
|
||||
assert.deepEqual(
|
||||
bounds('imm_words'),
|
||||
[
|
||||
[earlyFirst, seen],
|
||||
[seen - 0.1, seen - 0.1],
|
||||
],
|
||||
'healthy rows are untouched',
|
||||
);
|
||||
assert.deepEqual(bounds('imm_kanji'), [[earlyFirst, seen]]);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('lexical rollup rebuild tolerates nullable legacy vocabulary text', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -384,7 +268,7 @@ test('current lexical rollup readiness accepts legacy integer state storage', ()
|
||||
state_value INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO imm_rollup_state(state_key, state_value)
|
||||
VALUES ('lexical_daily_rollups_version', 3);
|
||||
VALUES ('lexical_daily_rollups_version', 2);
|
||||
`);
|
||||
|
||||
assert.equal(areLexicalDailyRollupsReady(db), true);
|
||||
|
||||
@@ -18,9 +18,7 @@ const LOCAL_EPOCH_DAY_SQL = `
|
||||
) - 2440587.5 AS INTEGER)
|
||||
`;
|
||||
|
||||
// v3 re-derives visibility for rows a re-seen word forced visible and repairs
|
||||
// last_seen values left behind by session deletes.
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '3';
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION = '2';
|
||||
const LEXICAL_DAILY_ROLLUP_VERSION_KEY = 'lexical_daily_rollups_version';
|
||||
const VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE = 5_000;
|
||||
|
||||
@@ -28,32 +26,6 @@ export function localEpochDaySql(value: string): string {
|
||||
return LOCAL_EPOCH_DAY_SQL.replaceAll('%VALUE%', value);
|
||||
}
|
||||
|
||||
// Session deletes once rounded removed timestamps to whole seconds, missing
|
||||
// fractional last_seen extremes that pointed at deleted history. New seen_ms
|
||||
// values match observation time; legacy values were stamped later at flush.
|
||||
// Retention prunes the oldest sessions first, so healthy rows never exceed
|
||||
// their newest surviving occurrence. Rows with undated occurrences are skipped.
|
||||
//
|
||||
// first_seen is deliberately not repaired: a stale bound is indistinguishable
|
||||
// from real history whose occurrences retention pruned, and frequency does not
|
||||
// reliably tell them apart.
|
||||
function repairStaleLastSeen(db: DatabaseSync): void {
|
||||
for (const [table, occurrenceTable, col] of [
|
||||
['imm_words', 'imm_word_line_occurrences', 'word_id'],
|
||||
['imm_kanji', 'imm_kanji_line_occurrences', 'kanji_id'],
|
||||
] as const) {
|
||||
const newestSeen = `(SELECT MAX(o.seen_ms) FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id) / 1000.0`;
|
||||
db.exec(`
|
||||
UPDATE ${table}
|
||||
SET last_seen = ${newestSeen}
|
||||
WHERE last_seen > ${newestSeen}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ${occurrenceTable} o WHERE o.${col} = ${table}.id AND o.seen_ms IS NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWordRollupTriggers(db: DatabaseSync): void {
|
||||
const dayForNew = localEpochDaySql('NEW.first_seen');
|
||||
const dayForOld = localEpochDaySql('OLD.first_seen');
|
||||
@@ -214,7 +186,6 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
||||
lastId = vocabularyRows[vocabularyRows.length - 1]!.id;
|
||||
if (vocabularyRows.length < VOCABULARY_VISIBILITY_SCAN_BATCH_SIZE) break;
|
||||
}
|
||||
repairStaleLastSeen(db);
|
||||
db.exec('DELETE FROM imm_lexical_daily_rollups');
|
||||
db.exec(`
|
||||
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
|
||||
|
||||
@@ -298,11 +298,9 @@ export function planLexicalRemovalsForVideos(
|
||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||
}
|
||||
|
||||
// first_seen/last_seen are REAL seconds that keep millisecond fractions; never round
|
||||
// them, or comparisons against stored extremes miss and refreshes drift.
|
||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||
if (ms === null || !Number.isFinite(ms)) return null;
|
||||
return ms / 1000;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,13 +320,6 @@ export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan)
|
||||
applyRemovalsForEntity(db, 'kanji', plan.kanji);
|
||||
}
|
||||
|
||||
// A timestamp repair must keep subtraction-exact lifetime counts and any
|
||||
// first_seen date that the deleted occurrences cannot account for.
|
||||
type LexicalAggregateOverride = {
|
||||
frequency: number | null;
|
||||
firstSeen: number | null;
|
||||
};
|
||||
|
||||
function applyRemovalsForEntity(
|
||||
db: DatabaseSync,
|
||||
entity: LexicalEntity,
|
||||
@@ -367,7 +358,7 @@ function applyRemovalsForEntity(
|
||||
`UPDATE ${entityTable} SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = ?`,
|
||||
);
|
||||
|
||||
const needsExactRefresh = new Map<number, LexicalAggregateOverride>();
|
||||
const needsExactRefresh: number[] = [];
|
||||
|
||||
for (const removal of removals) {
|
||||
const current = selectStmt.get(removal.id) as {
|
||||
@@ -378,9 +369,17 @@ function applyRemovalsForEntity(
|
||||
if (!current) continue;
|
||||
|
||||
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
|
||||
// Legacy occurrences use flush time, which can follow the actual observation.
|
||||
// An earlier stored date may also belong to retention-pruned history. Only
|
||||
// move it when the removed timestamps prove that its observation was deleted.
|
||||
if (nextFrequency <= 0) {
|
||||
// The rows in scope are already gone by now, so anything still pointing at
|
||||
// this entity means the stored frequency was stale rather than exhausted.
|
||||
if (hasOccurrencesStmt.get(removal.id)) {
|
||||
needsExactRefresh.push(removal.id);
|
||||
} else {
|
||||
deleteStmt.run(removal.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
||||
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
||||
const firstSeenMayHaveMoved =
|
||||
@@ -389,26 +388,11 @@ function applyRemovalsForEntity(
|
||||
const lastSeenMayHaveMoved =
|
||||
current.lastSeen === null ||
|
||||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
||||
const exactRefreshOverride = {
|
||||
frequency: nextFrequency > 0 ? nextFrequency : null,
|
||||
firstSeen: firstSeenMayHaveMoved ? null : current.firstSeen,
|
||||
};
|
||||
if (nextFrequency <= 0) {
|
||||
// The rows in scope are already gone by now, so anything still pointing at
|
||||
// this entity means the stored frequency was stale rather than exhausted.
|
||||
if (hasOccurrencesStmt.get(removal.id)) {
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
} else {
|
||||
deleteStmt.run(removal.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
|
||||
// Undated pre-migration occurrences are invisible to the seeks below;
|
||||
// resolve their dates without discarding retention-pruned aggregates.
|
||||
// fall back to the full re-aggregate that resolves their dates.
|
||||
if (hasUndatedOccurrenceStmt.get(removal.id)) {
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
needsExactRefresh.push(removal.id);
|
||||
continue;
|
||||
}
|
||||
const minSeenMs = (minSeenStmt.get(removal.id) as { value: number | null }).value;
|
||||
@@ -416,13 +400,13 @@ function applyRemovalsForEntity(
|
||||
if (minSeenMs === null || maxSeenMs === null) {
|
||||
// Frequency says occurrences remain but none exist: stale row, let the
|
||||
// exact refresh reconcile (it deletes rows with nothing left).
|
||||
needsExactRefresh.set(removal.id, exactRefreshOverride);
|
||||
needsExactRefresh.push(removal.id);
|
||||
continue;
|
||||
}
|
||||
updateAggregatesStmt.run(
|
||||
nextFrequency,
|
||||
firstSeenMayHaveMoved ? toStoredSeenSeconds(Number(minSeenMs)) : current.firstSeen,
|
||||
toStoredSeenSeconds(Number(maxSeenMs)),
|
||||
Math.floor(Number(minSeenMs) / 1000),
|
||||
Math.floor(Number(maxSeenMs) / 1000),
|
||||
removal.id,
|
||||
);
|
||||
continue;
|
||||
@@ -432,17 +416,13 @@ function applyRemovalsForEntity(
|
||||
}
|
||||
|
||||
if (entity === 'word') {
|
||||
refreshWordAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||
refreshWordAggregates(db, needsExactRefresh);
|
||||
} else {
|
||||
refreshKanjiAggregates(db, [...needsExactRefresh.keys()], needsExactRefresh);
|
||||
refreshKanjiAggregates(db, needsExactRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshWordAggregates(
|
||||
db: DatabaseSync,
|
||||
wordIds: number[],
|
||||
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||
): void {
|
||||
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
if (wordIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -490,19 +470,15 @@ function refreshWordAggregates(
|
||||
continue;
|
||||
}
|
||||
updateStmt.run(
|
||||
overrides?.get(row.wordId)?.frequency ?? row.frequency,
|
||||
overrides?.get(row.wordId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
row.wordId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshKanjiAggregates(
|
||||
db: DatabaseSync,
|
||||
kanjiIds: number[],
|
||||
overrides?: ReadonlyMap<number, LexicalAggregateOverride>,
|
||||
): void {
|
||||
function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
if (kanjiIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -550,9 +526,9 @@ function refreshKanjiAggregates(
|
||||
continue;
|
||||
}
|
||||
updateStmt.run(
|
||||
overrides?.get(row.kanjiId)?.frequency ?? row.frequency,
|
||||
overrides?.get(row.kanjiId)?.firstSeen ?? toStoredSeenSeconds(row.firstSeen),
|
||||
toStoredSeenSeconds(row.lastSeen),
|
||||
row.frequency,
|
||||
Math.floor(row.firstSeen / 1000),
|
||||
Math.floor(row.lastSeen / 1000),
|
||||
row.kanjiId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { ensureLexicalDailyRollupTables, markLexicalDailyRollupsReady } from './lexical-rollups';
|
||||
import { isVocabularyStatsRowVisible, type VocabularyVisibilityRow } from './vocabulary-visibility';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
@@ -18,8 +17,7 @@ export interface TrackerPreparedStatements {
|
||||
wordUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
subtitleLineInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordVisibilityUpdateStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiIdSelectStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
wordLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
kanjiLineOccurrenceUpsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -1761,6 +1759,7 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
pos1 = COALESCE(NULLIF(imm_words.pos1, ''), excluded.pos1),
|
||||
pos2 = COALESCE(NULLIF(imm_words.pos2, ''), excluded.pos2),
|
||||
pos3 = COALESCE(NULLIF(imm_words.pos3, ''), excluded.pos3),
|
||||
vocabulary_visible = 1,
|
||||
first_seen = MIN(COALESCE(first_seen, excluded.first_seen), excluded.first_seen),
|
||||
last_seen = MAX(COALESCE(last_seen, excluded.last_seen), excluded.last_seen),
|
||||
frequency_rank = CASE
|
||||
@@ -1788,16 +1787,10 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
wordSelectStmt: db.prepare(`
|
||||
SELECT id, word, headword, reading, part_of_speech AS partOfSpeech,
|
||||
pos1, pos2, pos3, frequency_rank AS frequencyRank,
|
||||
vocabulary_visible AS vocabularyVisible
|
||||
FROM imm_words
|
||||
wordIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_words
|
||||
WHERE headword = ? AND word = ? AND reading = ?
|
||||
`),
|
||||
wordVisibilityUpdateStmt: db.prepare(`
|
||||
UPDATE imm_words SET vocabulary_visible = ? WHERE id = ?
|
||||
`),
|
||||
kanjiIdSelectStmt: db.prepare(`
|
||||
SELECT id FROM imm_kanji
|
||||
WHERE kanji = ?
|
||||
@@ -1849,18 +1842,14 @@ function incrementWordAggregate(
|
||||
occurrence.frequencyRank ?? null,
|
||||
);
|
||||
}
|
||||
const row = stmts.wordSelectStmt.get(occurrence.headword, occurrence.word, occurrence.reading) as
|
||||
| (VocabularyVisibilityRow & { id: number; vocabularyVisible: number })
|
||||
| null;
|
||||
const row = stmts.wordIdSelectStmt.get(
|
||||
occurrence.headword,
|
||||
occurrence.word,
|
||||
occurrence.reading,
|
||||
) as { id: number } | null;
|
||||
if (!row?.id) {
|
||||
throw new Error(`Failed to resolve imm_words id for ${occurrence.headword}`);
|
||||
}
|
||||
// The upsert keeps the first-seen POS metadata, so derive visibility from the
|
||||
// stored row (as the vocabulary summary does) rather than the incoming token.
|
||||
const visible = isVocabularyStatsRowVisible(row) ? 1 : 0;
|
||||
if (row.vocabularyVisible !== visible) {
|
||||
stmts.wordVisibilityUpdateStmt.run(visible, row.id);
|
||||
}
|
||||
return row.id;
|
||||
}
|
||||
|
||||
@@ -1963,16 +1952,18 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
currentMs,
|
||||
);
|
||||
const lineId = Number(lineResult.lastInsertRowid);
|
||||
// Match aggregate bounds to the observation, even if this write was queued
|
||||
// for a while. Flush time cannot identify which occurrence set first_seen.
|
||||
const seenMs = toDbTimestamp(Math.round(write.firstSeen * 1000));
|
||||
for (const occurrence of write.wordOccurrences) {
|
||||
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, seenMs);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
|
||||
}
|
||||
for (const occurrence of write.kanjiOccurrences) {
|
||||
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount, seenMs);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(
|
||||
lineId,
|
||||
kanjiId,
|
||||
occurrence.occurrenceCount,
|
||||
currentMs,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ test('handleMultiCopyDigit copies backward from the current subtitle after a bac
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
@@ -295,6 +296,9 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: (message, err) => logs.push({ message, err }),
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
@@ -303,6 +307,37 @@ test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
assert.equal(logs[0]?.message, 'mineSentenceMultiple failed:');
|
||||
assert.equal((logs[0]?.err as Error).message, 'mine boom');
|
||||
assert.ok(osd.some((entry) => entry.includes('Mine sentence failed: mine boom')));
|
||||
assert.equal(cardsMined, 0);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit increments successful card count', async () => {
|
||||
const osd: string[] = [];
|
||||
let cardsMined = 0;
|
||||
|
||||
handleMineSentenceDigit(2, {
|
||||
subtitleTimingTracker: {
|
||||
getRecentBlocks: () => ['one', 'two'],
|
||||
getCurrentSubtitle: () => null,
|
||||
findTiming: (text) =>
|
||||
text === 'one' ? { startTime: 1, endTime: 3 } : { startTime: 4, endTime: 7 },
|
||||
},
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async () => true,
|
||||
},
|
||||
getCurrentSecondarySubText: () => 'sub2',
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
logError: () => {},
|
||||
onCardsMined: (count) => {
|
||||
cardsMined += count;
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(cardsMined, 1);
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats', async () => {
|
||||
|
||||
@@ -201,6 +201,7 @@ export function handleMineSentenceDigit(
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined?: (count: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.subtitleTimingTracker || !deps.ankiIntegration) return;
|
||||
@@ -232,9 +233,14 @@ export function handleMineSentenceDigit(
|
||||
entries,
|
||||
deps.getCurrentSecondarySubText,
|
||||
);
|
||||
// Card creation records the mined card (with its note id) itself.
|
||||
const cardsToMine = 1;
|
||||
deps.ankiIntegration
|
||||
.createSentenceCard(sentence, rangeStart, rangeEnd, secondarySubText)
|
||||
.then((created) => {
|
||||
if (created) {
|
||||
deps.onCardsMined?.(cardsToMine);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
deps.logError('mineSentenceMultiple failed:', err);
|
||||
deps.showMpvOsd(`Mine sentence failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
|
||||
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
|
||||
import { MediaGenerator } from '../../../media-generator.js';
|
||||
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
@@ -368,7 +369,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
||||
}
|
||||
|
||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
const tags = [...new Set([...(ankiConfig.tags ?? ['SubMiner']), STATS_MINING_TAG])];
|
||||
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
|
||||
@@ -300,3 +300,65 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
||||
);
|
||||
assert.ok(tokenizedTexts.includes('other'));
|
||||
});
|
||||
|
||||
test('prefetch service keeps an in-flight result from a superseded run', async () => {
|
||||
const cues = makeCues(5);
|
||||
const cachedTexts: string[] = [];
|
||||
let releaseFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === 'line-0') await firstGate;
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
getCacheGeneration: () => 0,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
await flushMicrotasks();
|
||||
service.stop();
|
||||
releaseFirst();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(cachedTexts, ['line-0']);
|
||||
});
|
||||
|
||||
test('prefetch service drops an in-flight result after cache invalidation', async () => {
|
||||
const cues = makeCues(5);
|
||||
const cachedTexts: string[] = [];
|
||||
let generation = 0;
|
||||
let releaseFirst!: () => void;
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const service = createSubtitlePrefetchService({
|
||||
cues,
|
||||
tokenizeSubtitle: async (text) => {
|
||||
if (text === 'line-0') await firstGate;
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
preCacheTokenization: (text) => {
|
||||
cachedTexts.push(text);
|
||||
},
|
||||
getCacheGeneration: () => generation,
|
||||
priorityWindowSize: 3,
|
||||
});
|
||||
|
||||
service.start(0);
|
||||
await flushMicrotasks();
|
||||
service.stop();
|
||||
generation += 1;
|
||||
releaseFirst();
|
||||
await flushMicrotasks();
|
||||
|
||||
assert.deepEqual(cachedTexts, []);
|
||||
});
|
||||
@@ -7,6 +7,12 @@ export interface SubtitlePrefetchServiceDeps {
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
hasCachedTokenization?: (text: string) => boolean;
|
||||
/**
|
||||
* Bumped whenever the tokenization cache is invalidated. A result is cached only if
|
||||
* the generation is unchanged since its tokenization began; without this dep every
|
||||
* result counts as fresh.
|
||||
*/
|
||||
getCacheGeneration?: () => number;
|
||||
priorityWindowSize?: number;
|
||||
}
|
||||
|
||||
@@ -88,9 +94,12 @@ export function createSubtitlePrefetchService(
|
||||
}
|
||||
warmedKeys.add(cacheKey);
|
||||
|
||||
// Freshness follows the cache generation, not the run: a seek or restart that
|
||||
// supersedes this run leaves the result valid, and the parser time is already spent.
|
||||
const generation = deps.getCacheGeneration?.();
|
||||
try {
|
||||
const result = await deps.tokenizeSubtitle(cue.text);
|
||||
if (result && !stopped && runId === currentRunId) {
|
||||
if (result && generation === deps.getCacheGeneration?.()) {
|
||||
deps.preCacheTokenization(cue.text, result);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface SubtitleProcessingController {
|
||||
*/
|
||||
notePlainSubtitleEmitted: (text: string) => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
/** Incremented by invalidateTokenizationCache; lets async writers detect stale results. */
|
||||
getCacheGeneration: () => number;
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
hasCachedSubtitle: (text: string) => boolean;
|
||||
@@ -253,6 +255,7 @@ export function createSubtitleProcessingController(
|
||||
tokenizationCache.clear();
|
||||
cacheGeneration += 1;
|
||||
},
|
||||
getCacheGeneration: () => cacheGeneration,
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => {
|
||||
setCachedTokenization(text, data);
|
||||
},
|
||||
|
||||
@@ -4362,6 +4362,26 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds'
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle skips frequency requests for ranks supplied by the scanner', async () => {
|
||||
const deps = makeDepsFromYomitanTokens(
|
||||
[{ surface: '猫', reading: 'ねこ', headword: '猫', frequencyRank: 42 }],
|
||||
{ getFrequencyDictionaryEnabled: () => true },
|
||||
);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
assert.ok(parserWindow);
|
||||
deps.getYomitanParserWindow = () => parserWindow;
|
||||
const scripts: string[] = [];
|
||||
const execute = parserWindow.webContents.executeJavaScript.bind(parserWindow.webContents);
|
||||
parserWindow.webContents.executeJavaScript = async (script) => {
|
||||
scripts.push(script);
|
||||
return execute(script);
|
||||
};
|
||||
const result = await tokenizeSubtitle('猫', deps);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 42);
|
||||
assert.ok(scripts.length > 0);
|
||||
assert.equal(scripts.filter((script) => script.includes('getTermFrequencies')).length, 0);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'第二走者',
|
||||
|
||||
@@ -494,6 +494,7 @@ function buildYomitanFrequencyTermReadingList(
|
||||
): Array<{ term: string; reading: string | null }> {
|
||||
const termReadingList: Array<{ term: string; reading: string | null }> = [];
|
||||
for (const token of tokens) {
|
||||
if (normalizePositiveFrequencyRank(token.frequencyRank) !== null) continue;
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
|
||||
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user