Compare commits

...
Author SHA1 Message Date
sudacode 33e694bc61 fix(anki): keep playback paused after overlay pause during timing review
- Treat overlay pause requests during an open media timing review as final: drop held resumes and the review's own playback restore
- Add cancelPlaybackResume to the media timing review runtime
- Test both paused and playing starting states
2026-09-23 01:36:11 -07:00
sudacode 430120cc63 fix(anki): hold playback paused while media timing review is open
- Defer overlay resume requests (popup closed, hover left) during an active review and apply them when the review closes
- Add deferPlaybackResume to the media timing review runtime and gate sendRendererMpvCommand on it
- Document the paused-playback behavior and add a changelog fragment
2026-09-23 00:32:35 -07:00
sudacode 294e3a777e chore(hachidori): update submodule to upstream d68089e
Merge 104 upstream Hachidori commits into the subminer fork, including
rebuilt engine artifacts and upstream glyph hover hit-testing.
2026-09-22 22:42:05 -07:00
sudacode c673b71053 fix(dictionary): restore Anki proxy marker when Hachidori sync fails
- Revert the proxy marker to its previous value when every settings write attempt fails, so it no longer claims a server that was never written
- Add a test for the all-writes-fail path
- Retry Windows managed runtime cleanup in the test while bun.exe stays locked after exit
2026-09-22 22:08:51 -07:00
sudacode d31a28c54f fix(dictionary): handle Hachidori extension load failures gracefully
- Catch errors from the Hachidori extension runtime and log them
- Clear the extension and session state and return null instead of rejecting
2026-09-22 21:54:49 -07:00
sudacode d100789232 fix(anki): regenerate sentence furigana when mined context changes
- Regenerate SentenceFurigana from the final sentence after timing-review expansion or stats-dashboard word mining
- Add a Yomitan parseText-based generator with a 10s timeout and escaped, highlighted output
- Clear stale furigana on failure so templates fall back to Sentence
- Keep existing furigana formatting when the sentence is unchanged
- Document the behavior in the anki-integration and immersion-tracking docs
2026-09-22 21:46:06 -07:00
sudacode 9a5fb02cbd fix(dictionary): sync Hachidori deck to ankiConnect.deck for polling
- Always set the first Hachidori Anki template deck to ankiConnect.deck so polling mode enriches Hachidori cards; other custom template settings stay intact
- Treat right-clicks retargeted to the Hachidori popup shadow host as overlay interactions so they no longer raise mpv or toggle pause
- Update anki-integration docs and changelog fragment
2026-09-22 21:43:53 -07:00
sudacode 2ba820f41a fix(dictionary): keep Hachidori lookup highlight on in SubMiner builds
Hachidori's overlay-mode first-install options turn off "Highlight the
word on the page" because GSM-style hosts screenshot the see-through
viewport. SubMiner captures media from mpv, and without the highlight
the subtitle sidebar gave no sign of which word was looked up or where
the match ended. The staged copy now seeds the highlight on; the fork
source is unchanged.
2026-09-22 21:06:19 -07:00
sudacode c39dbb5a8d fix(overlay): keep Hachidori attention from pausing playback
Hachidori's hachidori-popup-shown/hidden pair means "the reader needs
mouse events", which includes a left press anywhere on the overlay that
may start a selection. SubMiner treated it as popup visibility, so a
click paused and resumed mpv, and holding the button kept it paused.
The host element only exists after the first lookup, so the first
attempt (gate on an open popup pane when a host exists) still paused
on clicks before any lookup.

Once a Hachidori event has been seen, popup auto-pause now requires an
unhidden popup pane in the host's shadow root, and rechecks after each
successful lookup so drag-select popups still pause.
2026-09-22 21:06:19 -07:00
sudacode 2958591ec9 fix(dictionary): open Hachidori hover popups only over subtitle glyphs
Pin the Hachidori fork fix that stops the caret snapping into the line box
from looking up a subtitle's first word when the pointer is beside the line,
which kept the popup open and playback paused.
2026-09-22 20:39:35 -07:00
sudacode c9880efee3 test(anilist): apply sqlite pragmas in cover art fetcher tests
- Call applyPragmas before ensureSchema so test databases match the production setup
2026-09-22 19:30:31 -07:00
sudacode f225ac3c89 fix(dictionary): block --yomitan when an external profile is configured
- Keep read-only external-profile mode while Hachidori is active by checking the configured externalProfilePath
- Hachidori settings remain available
- Smoke check asserts the external-profile block, with a --bundled-yomitan path for the bundled case
- Update usage docs and changelog fragment
2026-09-22 19:03:39 -07:00
sudacode cacaa2b371 feat(overlay): add subtitle selection modal and Jellyfin 12 fixes
- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling
- Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info
- Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher
- Bump electron-builder to 26.16.1
- Condense and reconcile changelog fragments; update config example and docs
2026-09-22 18:51:59 -07:00
sudacode 9fe4c7eb33 fix(anki): preserve managed proxy state when settings sync fails 2026-09-22 15:49:24 -07:00
sudacode ab5e50adac fix(dictionary): complete Hachidori imports and mining integration 2026-09-22 15:01:23 -07:00
sudacode 15c74ef502 fix(dictionary): update Hachidori overlay sharing and test fixes 2026-09-22 14:26:32 -07:00
sudacode f0d3c95b4c fix(dictionary): pin Hachidori stats mining fix 2026-09-22 14:04:09 -07:00
sudacode d7e1d7edc6 refactor: configure Hachidori in the SubMiner build 2026-09-22 12:26:37 -07:00
sudacode 5354fdf7b9 refactor(dictionary): use Hachidori fork submodule and upstream API 2026-09-22 01:22:14 -07:00
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00
133 changed files with 5088 additions and 325 deletions
+2
View File
@@ -20,6 +20,8 @@ coverage/
/main-entry.js
/main-entry.js.map
/build/hachidori/
# Logs
*.log
npm-debug.log*
+4
View File
@@ -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
+5 -2
View File
@@ -4,7 +4,7 @@
# SubMiner
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -25,6 +25,8 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
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>
@@ -275,7 +277,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
@@ -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.
+14
View File
@@ -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 through `hachidori.externalHostManagementUrl`, retry busy imports, and replace the previous dictionary only after a successful import.
- 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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+16
View File
@@ -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
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// Used only while Hachidori is linked to an external host.
// ==========================================
"hachidori": {
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
+14
View File
@@ -21,6 +21,14 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
### Reusing SubMiner settings in Hachidori
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It sets the template's deck to `ankiConnect.deck` when one is configured, copies the configured tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set.
If the note type is unset, SubMiner looks for a unique match containing its configured word and sentence fields. Enabled Lapis, Kiku, or Senren integration narrows the search; Lapis uses its configured model name. A fresh mapping also receives Hachidori's matching preset for readings, definitions, and other recognized fields. If several note types match, choose one in Hachidori Settings. If Anki is closed, open Hachidori Settings again after starting Anki to retry.
The deck always follows `ankiConnect.deck`, as it does for Yomitan's mining deck, because polling mode only looks for new cards in that deck. Existing custom tags, field mappings, advanced templates, and additional templates stay intact. Apart from the deck, this fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior.
## Auto-enrichment transport
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
@@ -141,6 +149,8 @@ Field names are matched against your Anki note type case-insensitively (an exact
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
When Hachidori mines through SubMiner's Anki proxy, it prepares downloadable word audio before saving the note so the animation delay can be measured on the first mine. Configure a downloadable pronunciation source in Hachidori's Audio settings; browser speech cannot be saved into Anki by the SubMiner overlay. If pronunciation is unavailable, Hachidori reports a warning and the card has no word-audio hold.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
@@ -192,6 +202,8 @@ Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
Playback stays paused while the review is open, even if the dictionary popup or subtitle hover that paused it goes away. When the review closes, playback resumes if it was playing before the review or if the popup closed in the meantime. A dictionary popup that is still open keeps playback paused.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
@@ -317,6 +329,8 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
Field grouping runs when a new note is added with known duplicates. With the Hachidori backend that is the popup's **Add anyway** choice; **Overwrite** updates the existing note in place and only receives media enrichment.
```jsonc
"ankiConnect": {
"isKiku": {
+8
View File
@@ -171,6 +171,14 @@ The configuration file includes several main sections:
## 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` specifies the linked Docker host's HTTP(S) management origin for automatic character dictionary uploads and replacement. Use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup) for an example and [the generated configuration example](/config.example.jsonc) for the default.
### Logging
Control the minimum log level for runtime output:
+3 -1
View File
@@ -152,12 +152,14 @@ Stats server config lives under `stats`:
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Word** - looks up the word with the selected dictionary backend, Yomitan or Hachidori, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. The selected history line supplies the card's context even while another subtitle is playing in mpv. Hachidori uses its configured Anki template, dictionary aliases, and frequency metadata. Requires Anki and the selected backend's dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Stats cards also receive the `SubMiner::Stats` tag. SubMiner uses it to preserve their selected history context when detecting new cards through polling with the Anki proxy disabled.
Secondary subtitle text is stored alongside primary subtitles during playback, but the Search tab does not use it for display or matching.
### Word exclusion list
+16
View File
@@ -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
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// Used only while Hachidori is linked to an external host.
// ==========================================
"hachidori": {
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
+2 -2
View File
@@ -15,7 +15,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
| `Alt+Shift+Y` | Open active dictionary settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
@@ -127,7 +127,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix - press `y
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
| `y-o` | Open Yomitan settings |
| `y-o` | Open active dictionary settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
+27 -4
View File
@@ -15,8 +15,8 @@ That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socke
Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine.
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
> See [Yomitan setup](#yomitan-setup) for details.
> SubMiner requires at least one dictionary in the selected lookup backend.
> See [Yomitan setup](#yomitan-setup) or [Hachidori setup](#hachidori-setup).
::: tip Anki card enrichment
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
@@ -174,6 +174,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle b
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --hachidori # Open Hachidori settings
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
@@ -208,7 +209,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
### Windows mpv shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
First-run setup creates the config file, then requires dictionaries in the selected backend before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -314,6 +315,28 @@ For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yom
If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance.
### Hachidori setup
Set `dictionaryBackend` to `"hachidori"` in SubMiner settings or `config.jsonc`, then restart SubMiner. The tray's dictionary settings entry changes to **Open Hachidori Settings**. Switching to `"yomitan"` restores the Yomitan entry after restarting.
Open Hachidori settings with `subminer app --hachidori` or `SubMiner.AppImage --hachidori`. Import your dictionary ZIPs or use Hachidori's recommended dictionary installer, then configure its Anki templates. Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not transfer to Hachidori.
First-run setup also offers **Dictionary source → Use an external dictionary host → Link host**. Enable sharing in the other Hachidori app or browser, or start a compatible Docker dictionary host, then enter its sharing address, such as `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port. The external host section is collapsed until you expand it or a host is linked. Browser hosts need the browser, Hachidori extension, and relay running. Electron hosts need the host app and any required relay running. Docker hosts need the container running; no browser needs to stay open.
Setup checks the host connection and dictionary inventory before enabling Finish. Import at least one dictionary on the host and refresh status. The link persists across restarts. **Unlink and use local dictionaries** restores SubMiner's local library. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio/image processing remain local while linked. Dictionary settings and dictionary edits use the host. Frequency annotations use the frequencies returned with Hachidori dictionary entries. SubMiner keeps ranks found during scanning and queries the existing term-entry API for missing ranks. Words without a matching definition entry may remain unranked, even if a frequency dictionary contains them.
Both named settings flags work independently of the selected backend. Opening settings does not switch the overlay backend. The global dictionary-settings shortcut opens the selected backend.
Hachidori uses SubMiner's subtitle scanning, lookup counter, popup pause behavior, controller commands, character dictionaries, and Anki media enrichment. Keep SubMiner's AnkiConnect proxy enabled for screenshots and sentence audio. SubMiner routes Hachidori to that proxy when it is active; Hachidori's own screen recorder and screenshot capture are disabled in the embedded app.
For automatic character dictionary sync with a Docker host, set `hachidori.externalHostManagementUrl` to that same host's management origin, for example `"http://127.0.0.1:8780"`. This is separate from the WebSocket sharing address. SubMiner uploads the generated ZIP directly and replaces its previous dictionary after a successful import; busy imports are retried. Keep this URL pointed at the currently linked Docker host if you change hosts. An empty value disables external uploads and reports a configuration error when sync is attempted. Local Hachidori dictionaries do not need this setting. External browser/app hosts without the Docker management API do not support this automatic upload path.
Existing controls such as `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to the selected backend. Hachidori has one dictionary configuration, so character-dictionary profile scope applies to that configuration.
First-run setup remembers each backend that finished it, including when setup is reopened for legacy plugin cleanup. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. A running Yomitan session continues using its external profile until the restart. When `yomitan.externalProfilePath` is configured, `--yomitan` is disabled to preserve read-only external-profile mode, including while Hachidori is active. Hachidori settings remain available.
Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well.
### YouTube playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
@@ -408,7 +431,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
| Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
| `Alt+Shift+Y` | Open active dictionary settings | OS-global - registered with the system, works from any window |
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
+6
View File
@@ -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), and removes the unsupported `userScripts` permission 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 requires an unhidden popup pane in the host's shadow root and rechecks after each successful lookup. 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
+1
View File
@@ -63,6 +63,7 @@ bun run docs:build
Content checks and informational size reporting run inside electron-builder hooks. See the
[release guide](../RELEASING.md#package-contents-and-size-checks) for size reports
and 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
+7 -1
View File
@@ -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: () =>
+7
View File
@@ -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;
}
+4
View File
@@ -241,6 +241,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;
+2
View File
@@ -86,6 +86,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')
+56 -3
View File
@@ -282,7 +282,8 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher settings option forwards app settings window command', () => {
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');
@@ -299,12 +300,13 @@ test('launcher settings option forwards app settings window command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--settings'], env);
const result = runLauncher([flag], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
});
});
}
test('launcher settings command forwards app settings window command', () => {
withTempDir((root) => {
@@ -1110,3 +1112,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);
});
});
+9
View File
@@ -344,3 +344,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
View File
@@ -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
View File
@@ -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';
+8 -2
View File
@@ -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"
+64
View File
@@ -0,0 +1,64 @@
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: !IS_FIREFOX,', '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');
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`);
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
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',
),
});
});
+139
View File
@@ -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);
});
+239
View File
@@ -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);
});
+137
View File
@@ -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}' },
],
mediaCapture: { enabled: false },
}),
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);
});
+132
View File
@@ -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');
`);
});
+8
View File
@@ -27,6 +27,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',
+26
View File
@@ -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 });
}
+40 -44
View File
@@ -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`);
}
} 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.',
);
}
+3
View File
@@ -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;
+47 -6
View File
@@ -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();
+19
View File
@@ -8,6 +8,7 @@ import {
isStandaloneTexthookerCommand,
parseArgs,
shouldRunYomitanOnlyStartup,
shouldRunDictionarySettingsOnlyStartup,
shouldStartApp,
} from './args';
@@ -475,3 +476,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
View File
@@ -13,6 +13,7 @@ export interface CliArgs {
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
hachidori: boolean;
settings: boolean;
syncWindow: boolean;
setup: boolean;
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -285,6 +287,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 === '--setup') args.setup = true;
@@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.hachidori &&
!args.settings &&
!args.syncWindow &&
!args.setup &&
@@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -769,8 +775,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 &&
+1
View File
@@ -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/);
+1
View File
@@ -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
+4
View File
@@ -22,6 +22,8 @@ export type {
} from './definitions/shared';
const {
dictionaryBackend,
hachidori,
subtitlePosition,
keybindings,
websocket,
@@ -58,6 +60,8 @@ const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
hachidori,
subtitlePosition,
keybindings,
websocket,
+4
View File
@@ -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: {
+14
View File
@@ -81,6 +81,20 @@ export function buildCoreConfigOptionRegistry(
] as const;
return [
{
path: 'hachidori.externalHostManagementUrl',
kind: 'string',
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
description:
'Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.',
},
{
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: [
'Configure the linked Docker host management URL, for example http://127.0.0.1:8780.',
],
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.'],
+27
View File
@@ -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');
}
});
+7 -3
View File
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Dictionary Lookup',
'Yomitan',
'Stats dashboard',
'Startup warmups',
@@ -234,8 +235,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',
@@ -276,7 +277,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':
@@ -340,6 +341,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', () => {
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
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',
@@ -108,6 +110,7 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
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',
@@ -191,6 +194,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:
@@ -296,6 +300,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',
@@ -360,6 +365,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',
@@ -438,6 +444,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',
@@ -545,6 +552,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',
@@ -662,6 +670,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',
+1
View File
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
+16
View File
@@ -20,6 +20,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: 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}`);
},
@@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
},
ui: {
openFirstRunSetup: () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -1133,3 +1138,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);
});
}
+9
View File
@@ -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;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -170,6 +171,7 @@ interface MiningCliRuntime {
interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime(
options.ui.openYomitanSettings();
}, delayMs);
},
openHachidoriSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openHachidoriSettings();
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
@@ -425,6 +432,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 });
}
});
+59
View File
@@ -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;
},
};
}
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: 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,
+20
View File
@@ -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(
'第二走者',
+1
View File
@@ -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)) {
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import vm from 'node:vm';
import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const config = {
tags: ['SubMiner', 'Japanese'],
fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' },
};
const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
async function harness(
anki: unknown = {},
models: Record<string, string[]> = { Japanese: modelFields },
) {
const templates: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href
);
const setup: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href
);
const context = vm.createContext({
__templates: templates,
__setup: setup,
__initialAnki: anki,
__models: models,
structuredClone,
URL,
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
vm.runInContext(
`
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
let writes = 0, online = true, race = false, proxy = null, failWrites = false;
const readOptions = async () => structuredClone(options);
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
const send = async (type, request, target) => {
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
if (failWrites) throw Error('offline');
if (race) {
race = false;
options.anki.templates[0].tags = options.anki.tags = ['User edit'];
options.revision++;
}
if (request.baseRevision !== options.revision) throw Error('conflict');
options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 };
writes++;
};
const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({
connected: online, model, models: Object.keys(__models), decks: ['Mining'],
fields: __models[model] || [], errors: online ? [] : ['offline'],
}) }) };
`,
context,
);
await vm.runInContext(
HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates')
.replace("await import('./anki-setup.js')", '__setup')
.replace("await import('./anki.js')", '__gateway'),
context,
);
const run = async (script: string): Promise<unknown> =>
structuredClone(await vm.runInContext(script, context));
const sync = () =>
run(
`__subminerSyncAnkiSettings(${JSON.stringify({
server: 'http://127.0.0.1:8766',
deck: 'Mining',
forceOverride: true,
hints: buildHachidoriAnkiHints(config),
})})`,
);
return { run, sync };
}
test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => {
const h = await harness();
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
assert.deepEqual(
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
);
assert.deepEqual(
await h.run(
'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))',
),
{
Term: '{expression}',
Reading: '{reading}',
Definition: '{definition}',
Context: '{sentence}',
Pronunciation: '{audio}',
Image: '{screenshot}',
},
);
await h.sync();
assert.equal(await h.run('writes'), 1);
});
// SubMiner's new-card polling only watches ankiConnect.deck, so the first
// template's deck follows it the way Yomitan's term card deck does.
test('moves the first template to the SubMiner deck and preserves the rest of custom templates', async () => {
const h = await harness({
templates: [
{
id: 'default',
name: 'Custom',
deck: 'Own deck',
model: 'Japanese',
tags: ['own'],
fieldTemplates: {
Term: { value: '{reading}', overwriteMode: 'overwrite' },
Context: { value: '', overwriteMode: 'coalesce' },
},
},
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
],
});
const before = (await h.run('options.anki.templates')) as Array<Record<string, unknown>>;
await h.sync();
assert.deepEqual(await h.run('options.anki.templates'), [
{ ...before[0], deck: 'Mining' },
...before.slice(1),
]);
assert.equal(await h.run('options.anki.deck'), 'Mining');
});
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
const h = await harness({}, { Japanese: modelFields, Second: modelFields });
await h.run('online = false');
assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true });
assert.equal(await h.run('options.anki.deck'), 'Mining');
await h.run('online = true');
await h.sync();
assert.equal(await h.run('options.anki.model'), '');
await h.run('delete __models.Second');
await h.sync();
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('fills missing basic mappings only with fields belonging to the selected model', async () => {
const h = await harness(
{ model: 'Japanese', fields: { expression: 'Reading' } },
{ Japanese: ['Term', 'Reading', 'Context'] },
);
await h.sync();
assert.deepEqual(
await h.run(
'[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]',
),
['Reading', 'Context', '', null],
);
});
test('re-reads concurrent settings edits before retrying its revisioned write', async () => {
const h = await harness();
await h.run('race = true');
await h.sync();
assert.deepEqual(await h.run('options.anki.tags'), ['User edit']);
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('restores the previous proxy marker when every settings write fails', async () => {
const h = await harness();
await h.run(`proxy = 'http://127.0.0.1:9000'; failWrites = true`);
await assert.rejects(h.sync(), /offline/);
assert.equal(await h.run('proxy'), 'http://127.0.0.1:9000');
});
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
const hints = buildHachidoriAnkiHints({
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
});
assert.equal(hints.fields.audio, 'WordAudio');
assert.equal('captureAudio' in hints.fields, false);
});
@@ -0,0 +1,124 @@
import type { AnkiConnectConfig } from '../../../types';
// Only settings with the same meaning in both apps cross this boundary.
export function buildHachidoriAnkiHints(config: AnkiConnectConfig) {
return {
tags: config.tags,
fields: {
expression: config.fields?.word?.trim(),
audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(),
sentence: config.fields?.sentence?.trim(),
screenshot: config.fields?.image?.trim(),
},
model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined,
family: config.isLapis?.enabled
? 'lapis'
: config.isKiku?.enabled
? 'kiku'
: config.isSenren?.enabled
? 'senren'
: undefined,
};
}
// Installed inside the hidden Hachidori settings page. Uses local option writes
// even when dictionaries are remote or their host is offline.
export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => {
const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null);
const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js');
const { ankiSetupFamily } = await import('./anki-setup.js');
const { createAnkiGateway } = await import('./anki.js');
const gateway = createAnkiGateway({ timeoutMs: 2000 });
const discoveries = new Map();
const discover = async (anki, model) => {
const key = JSON.stringify([anki.url, anki.apiKey, model]);
if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model }));
return discoveries.get(key);
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const options = await readOptions();
let anki = options.anki;
const canReplaceServer = forceOverride || !anki.url || anki.url === server
|| anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy;
if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' };
anki = { ...anki, url: server };
const first = anki.templates[0];
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
let template = { ...first, fields: { ...first.fields } };
// SubMiner's new-card polling only watches its configured deck, so the
// first template follows it like Yomitan's term card deck.
if (deck) template.deck = deck;
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
template.tags = [...hints.tags];
}
let pending = false;
// Advanced templates include intentionally blank fields. Preserve them
// as a unit rather than replacing them with inferred mappings.
if (hints && first.fieldTemplates === null) {
let discovery = await discover(anki, template.model || hints.model || '');
if (!discovery.connected) {
pending = true;
} else {
if (!template.model) {
const candidates = hints.model
? discovery.models.filter(model => model === hints.model)
: discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family);
const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean);
const matches = [];
// Require a configured word and sentence field for an inferred
// model. Never choose the first of several compatible note types.
if (anchors.length === 2) for (const model of candidates) {
const result = await discover(anki, model);
if (!result.connected || result.errors.length) { pending = true; break; }
if (anchors.every(field => result.fields.includes(field))) matches.push(result);
}
if (!pending && matches.length === 1) {
discovery = matches[0];
template.model = discovery.model;
}
}
if (template.model && discovery.model === template.model && discovery.fields.length) {
const fields = discovery.fields;
for (const [semantic, field] of Object.entries(hints.fields)) {
if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field;
}
if (Object.values(first.fields).every(value => !value)) {
const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic');
const configured = resolveAnkiTemplates(template, fields).templates;
const markers = new Set(Object.entries(template.fields)
.filter(([, value]) => value).map(([key]) => '{' + key + '}'));
for (const row of Object.values(preset.fieldTemplates)) {
for (const marker of markers) row.value = row.value.replaceAll(marker, '');
}
for (const [field, row] of Object.entries(configured)) {
if (row.value) preset.fieldTemplates[field] = row;
}
template = preset;
}
}
}
}
// Hachidori retains a compatibility projection of its first template.
// Updating both prevents its normalizer from restoring stale values.
const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS
.map(key => [key, template[key]]));
anki = { ...anki, ...templateConfig,
templates: [template, ...anki.templates.slice(1)] };
const changed = JSON.stringify(anki) !== JSON.stringify(options.anki);
try {
if (changed) await send('hd_options_write', {
baseRevision: options.revision, options: { anki },
}, 'hoshidicts-worker');
return { updated: changed, matched: !pending, pending };
} catch (error) {
if (attempt > 0) {
// The server was never written, so the marker must not claim it.
await globalThis.__subminerSetAnkiProxyUrl(previousProxy);
throw error;
}
// Re-read after a concurrent settings save before filling anything.
}
}
};
`;
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { once } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
test('linked Hachidori uploads replacement bytes, retries a busy host, and never invokes local import', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
const archive = Buffer.from('PK-test-archive');
await writeFile(zipPath, archive);
let attempts = 0;
const server = createServer(async (request, response) => {
attempts += 1;
assert.equal(request.url, '/import?name=merged.zip&replace=true');
assert.equal(request.method, 'POST');
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
assert.deepEqual(Buffer.concat(chunks), archive);
response.writeHead(attempts === 1 ? 409 : 200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify(attempts === 1 ? { error: 'busy' } : { ok: true, report: { success: true } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
const managementUrl = `http://127.0.0.1:${address.port}`;
let connected = true;
const deps = {
...createDeps(async (script) => {
assert.ok(script.includes('hd_sharing_status'), 'must not invoke local ZIP automation');
return {
ok: true,
sharing: {
client: {
linked: true,
connected,
address: 'ws://127.0.0.1:8771/link',
host: { dictionaryCount: 8 },
},
},
};
}),
getYomitanExt: () => ({
id: 'hachi',
name: 'Hachidori',
version: '1',
path: '',
url: '',
manifest: {},
}),
};
try {
assert.equal(
await importYomitanDictionaryFromZip(zipPath, deps, { error: assert.fail }, managementUrl),
true,
);
assert.equal(attempts, 2);
const errors: string[] = [];
const logger = { error: (...args: unknown[]) => errors.push(args.join(' ')) };
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger), false);
assert.match(errors.pop() ?? '', /externalHostManagementUrl/);
connected = false;
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false);
assert.equal(attempts, 2);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('Hachidori upload requires a successful import report, not just HTTP success', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'bad archive');
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({ ok: true, report: { success: false, error: 'Invalid archive' } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
try {
await assert.rejects(
uploadHachidoriDictionary(zipPath, `http://127.0.0.1:${address.port}`),
/Invalid archive/,
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -0,0 +1,59 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { parseHachidoriManagementUrl } from '../../../shared/hachidori-sharing';
// Upload from the main process: extension blob URLs cannot cross the sharing link,
// and the Docker management API deliberately rejects browser cross-origin writes.
export async function uploadHachidoriDictionary(
zipPath: string,
managementUrl: string,
): Promise<void> {
const origin = parseHachidoriManagementUrl(managementUrl);
if (!origin) {
throw new Error(
'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.',
);
}
const url = new URL('/import', origin);
url.searchParams.set('name', path.basename(zipPath));
url.searchParams.set('replace', 'true');
const bytes = await readFile(zipPath);
const signal = AbortSignal.timeout(300_000);
for (;;) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: bytes,
signal,
redirect: 'error',
});
if (response.status === 409) {
await response.body?.cancel();
await delay(500, undefined, { signal });
continue;
}
const result: unknown = await response.json();
if (
response.ok &&
typeof result === 'object' &&
result !== null &&
'ok' in result &&
result.ok === true &&
'report' in result &&
typeof result.report === 'object' &&
result.report !== null &&
'success' in result.report &&
result.report.success === true
)
return;
const detail =
typeof result === 'object' &&
result !== null &&
'error' in result &&
typeof result.error === 'string'
? result.error
: JSON.stringify(result);
throw new Error(`Hachidori dictionary upload failed (${response.status}): ${detail}`);
}
}
@@ -0,0 +1,493 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import * as vm from 'node:vm';
import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge';
import {
requestYomitanScanTokens,
requestYomitanParseResults,
requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer,
addYomitanNoteViaSearch,
} from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
import { selectYomitanParseTokens } from './parser-selection-stage';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const characterDictionary = 'SubMiner Character Dictionary (AniList 1)';
function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') {
return {
matched,
deinflected: expression,
trace: [],
term: {
expression,
reading,
score: 0,
rules: 'v1',
glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }],
pitches: [],
frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }],
},
};
}
async function createHarness(emptyLibrary = false) {
const messages: Array<Record<string, unknown>> = [];
let dictionaryRevision = 2;
let optionRevision = 3;
let anki = {
url: 'http://127.0.0.1:8765',
templates: [
{ id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} },
{ id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} },
],
};
let dictionaries = [
{ id: 'terms', title: 'JMdict', displayName: 'Main dictionary', enabled: true, revision: '1' },
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
{
id: 'frequency',
title: 'Frequency',
enabled: true,
revision: '1',
frequencyMode: 'rank-based',
frequencyCount: 1,
},
];
let duplicate = false;
let proxyUrl: unknown;
let loadingStatusReplies = 0;
let busyEngineReplies = 0;
const apiModule: unknown = await import(
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
);
const context = vm.createContext({
__testApiModule: apiModule,
setTimeout,
crypto,
URL,
Blob,
Uint8Array,
atob,
chrome: {
storage: {
local: {
set: async (value: Record<string, unknown>) => {
proxyUrl = value.subminerAnkiProxyUrl;
},
get: async () => ({
options: { anki, scanLength: 40, revision: optionRevision },
subminerAnkiProxyUrl: proxyUrl,
}),
},
},
runtime: {
getManifest: () => ({ version: 'test' }),
sendMessage: async (message: Record<string, unknown>) => {
messages.push(structuredClone(message));
if (message.type === 'hd_status') {
const loading = loadingStatusReplies > 0;
if (loading) loadingStatusReplies -= 1;
return { ok: true, ready: !loading, loading };
}
if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') {
busyEngineReplies -= 1;
return { ok: false, error: 'the dictionary engine is busy mutating' };
}
switch (message.type) {
case 'hd_state_read':
return {
ok: true,
state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries },
};
case 'hd_lookup': {
const text = String(message.text);
const candidates = [
lookupResult('食べた', '食べる', 'たべる'),
lookupResult('食べる', '食べる', 'たべる'),
lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary),
];
return {
ok: true,
generation: 1,
results: candidates.filter((result) => text.startsWith(result.matched)),
};
}
case 'hd_options_write': {
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
const update = message.options;
assert.ok(update && typeof update === 'object' && 'anki' in update);
const value = update.anki;
assert.ok(
value &&
typeof value === 'object' &&
'url' in value &&
typeof value.url === 'string',
);
assert.ok('templates' in value && Array.isArray(value.templates));
anki = { url: value.url, templates: value.templates };
optionRevision += 1;
return { ok: true };
}
case 'hd_apply_state': {
assert.equal(message.baseRevision, dictionaryRevision);
assert.ok(Array.isArray(message.dictionaries));
dictionaries = message.dictionaries;
dictionaryRevision += 1;
return { ok: true };
}
case 'hd_anki_status':
return { ok: true, configKey: 'configuration' };
case 'hd_anki_preflight':
return {
ok: true,
canAdd: !duplicate,
state: duplicate ? 'duplicate' : 'addable',
noteIds: duplicate ? [15] : [],
};
case 'hd_anki_submit':
return { ok: true, state: 'added', noteId: 19 };
case 'hd_import':
return { ok: true, report: { success: true } };
case 'hd_remove':
return { ok: true };
default:
throw new Error('Unexpected native request: ' + String(message.type));
}
},
},
},
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
"await import('./api-host.js')",
'__testApiModule',
).replace("await import('./reader-options.js')", 'Promise.resolve()');
await vm.runInContext(script, context);
const run = async (code: string): Promise<unknown> =>
structuredClone(await vm.runInContext(code, context));
const invoke = (action: string, params?: unknown) =>
run(`new Promise((resolve, reject) => {
__subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => {
if (response.error) reject(new Error(response.error.message)); else resolve(response.result);
});
})`);
return {
deps: createDeps(run),
run,
invoke,
messages,
anki: () => anki,
proxyUrl: () => proxyUrl,
setAnkiServer: (url: string) => {
anki = { ...anki, url };
},
setAnkiTemplates: (templates: typeof anki.templates) => {
anki = { ...anki, templates };
},
dictionaries: () => dictionaries,
disableDictionary: (id: string) => {
dictionaries = dictionaries.map((entry) =>
entry.id === id ? { ...entry, enabled: false } : entry,
);
dictionaryRevision += 1;
},
setDuplicate: () => {
duplicate = true;
},
changeRevision: () => {
optionRevision += 1;
},
setLoadingStatusReplies: (count: number) => {
loadingStatusReplies = count;
},
setBusyEngineReplies: (count: number) => {
busyEngineReplies = count;
},
};
}
test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => {
const harness = await createHarness();
const tokens = await requestYomitanScanTokens(
'ミナト 食べた',
harness.deps,
{ error: assert.fail },
{
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
},
);
assert.ok(tokens);
assert.equal(tokens[0]?.surface, 'ミナト');
assert.equal(tokens[0]?.isNameMatch, true);
assert.equal(tokens[1]?.surface, '食べた');
assert.equal(tokens[1]?.headword, '食べる');
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 frequencies = await requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'たべる' }],
harness.deps,
{ error: assert.fail },
);
assert.equal(frequencies[0]?.frequency, 42);
assert.equal(frequencies[0]?.dictionary, 'Frequency');
});
test('Hachidori frequency lookups match API headwords, readings and requested dictionaries', async () => {
const harness = await createHarness();
const query = (term: string, reading: string | null, dictionaries = ['Frequency']) =>
harness.invoke('getTermFrequencies', { termReadingList: [{ term, reading }], dictionaries });
assert.deepEqual(await query('食べる', 'たべる'), [
{
term: '食べる',
reading: 'たべる',
hasReading: false,
dictionary: 'Frequency',
frequency: 42,
displayValue: null,
displayValueParsed: false,
},
]);
assert.deepEqual(await query('食べる', 'べつのよみ'), []);
assert.deepEqual(await query('食べる', null, ['Other frequency']), []);
assert.deepEqual(await query('食べるだけ', null), []);
assert.deepEqual(await query('頻度だけ', null), []);
assert.deepEqual(await query('食べる', null), await query('食べる', 'たべる'));
});
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
const harness = await createHarness();
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ deck: 'Mining' },
);
assert.equal(synced, true);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.deepEqual(
harness.anki().templates.map((template) => template.deck),
['Mining', 'Mining'],
);
assert.equal(harness.proxyUrl(), null);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766');
await assert.rejects(
harness.invoke('setAllSettings', {
value: {
hachidoriRevisions: { dictionaries: 99, options: 99 },
profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }],
},
}),
/settings changed/,
);
});
test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => {
const harness = await createHarness();
const projected = (await harness.invoke('optionsGetFull')) as {
profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>;
};
const jmdict = projected.profiles[0]!.options.dictionaries.find(
(entry) => entry.name === 'JMdict',
);
jmdict!.enabled = false;
// Hachidori changed both revisions after SubMiner read them.
harness.disableDictionary('names');
harness.setAnkiServer('http://192.168.1.10:8765');
harness.changeRevision();
assert.equal(await harness.invoke('setAllSettings', { value: projected }), true);
assert.deepEqual(
harness.dictionaries().map((entry) => [entry.id, entry.enabled]),
[
['terms', false],
['names', false],
['frequency', true],
],
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0);
});
test('Hachidori settings writes survive an empty template list', async () => {
const harness = await createHarness();
harness.setAnkiTemplates([]);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{
forceOverride: true,
},
),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
});
test('Hachidori initializes an empty library before the first dictionary import', async () => {
const harness = await createHarness(true);
assert.deepEqual(await harness.invoke('getDictionaryInfo'), []);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
});
test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
harness.setAnkiServer('http://192.168.1.10:8765');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
false,
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => {
const harness = await createHarness();
const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, {
error: assert.fail,
});
const tokens = selectYomitanParseTokens(parsed, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
reading: token.reading,
start: token.startPos,
})),
[
{ surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 },
{ surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 },
],
);
});
test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => {
const harness = await createHarness();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: 19,
duplicateNoteIds: [],
});
harness.setDuplicate();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: null,
duplicateNoteIds: [15],
});
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
});
test('Hachidori mining requests render native dictionary aliases and frequency markers', async () => {
const harness = await createHarness();
await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail });
const request = harness.messages.find((message) => message.type === 'hd_anki_preflight')?.request;
assert.ok(request && typeof request === 'object');
assert.ok('subminerEnrich' in request && request.subminerEnrich === false);
const native: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-values.js')).href
);
assert.ok(native && typeof native === 'object' && 'buildAnkiFields' in native);
assert.equal(typeof native.buildAnkiFields, 'function');
if (typeof native.buildAnkiFields !== 'function') assert.fail('Native renderer is unavailable');
const fields: unknown = await native.buildAnkiFields(
request,
{
Dictionary: { value: '{dictionary-alias}' },
Frequency: { value: '{single-frequency-frequency}' },
Rank: { value: '{frequency-harmonic-rank}' },
},
{},
);
assert.deepEqual(fields, {
Dictionary: 'Main dictionary',
Frequency: '<ul style="text-align: left;"><li>Frequency: </li></ul>',
Rank: '42',
});
assert.ok('dictionaryIds' in request);
assert.deepEqual(request.dictionaryIds, {
JMdict: 'terms',
[characterDictionary]: 'names',
Frequency: 'frequency',
});
assert.deepEqual(
harness.messages.find((message) => message.type === 'hd_anki_submit')?.request,
request,
);
});
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
const harness = await createHarness();
await harness.run(
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
);
await harness.run(
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
);
const imported = harness.messages.find((message) => message.type === 'hd_import');
assert.equal(imported?.fileName, 'characters.zip');
assert.match(String(imported?.blobUrl), /^blob:/);
const removed = harness.messages.find((message) => message.type === 'hd_remove');
assert.equal(removed?.id, 'names');
});
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
const harness = await createHarness();
harness.setLoadingStatusReplies(2);
harness.setBusyEngineReplies(1);
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
assert.equal(dictionaries.length, 3);
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
assert.equal(tokens?.[0]?.headword, '食べる');
assert.equal(tokens?.[0]?.frequencyRank, 42);
});
@@ -0,0 +1,270 @@
// Hachidori exposes its own storage and engine protocol. Adapt it only inside
// SubMiner's hidden extension windows so the shared scanner keeps its matching,
// character-name and frequency semantics without changing Hachidori's pages.
import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori';
export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
(async () => {
if (globalThis.__subminerDictionarySendMessage) return;
const { createApiHost } = await import('./api-host.js');
await import('./reader-options.js');
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
const reply = await chrome.runtime.sendMessage({
target, type, requestId: crypto.randomUUID(), ...fields,
});
if (!reply || reply.ok !== true) {
throw new Error(reply?.error || 'Hachidori returned an invalid response');
}
return reply;
};
// Hachidori reports an empty library while its dictionaries load and rejects
// engine requests during imports. Wait for a settled engine so SubMiner never
// caches an empty dictionary list or a token-less scan.
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const waitForEngineReady = async () => {
const deadline = Date.now() + 120000;
for (;;) {
const reply = await send('hd_status').catch(() => null);
if (reply?.ready === true && reply.loading !== true) return;
if (Date.now() >= deadline) {
throw new Error(reply?.ready === true
? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready');
}
await sleep(250);
}
};
const engine = async (type, fields = {}, target) => {
for (let attempt = 0; ; attempt += 1) {
await waitForEngineReady();
try {
return await send(type, fields, target);
} catch (error) {
if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error;
await sleep(250);
}
}
};
const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state
?? { revision: 0, dictionaries: [] };
const readOptions = async () => {
const stored = (await chrome.storage.local.get('options')).options;
return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 };
};
globalThis.__subminerSetAnkiProxyUrl = async url => {
const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl;
await chrome.storage.local.set({ subminerAnkiProxyUrl: url });
return typeof previous === 'string' ? previous : null;
};
${HACHIDORI_ANKI_SETTINGS_SCRIPT}
const api = createApiHost({
engine: fields => engine(fields.type, fields),
render: fields => send(fields.type, fields, 'hachidori-anki-render'),
readDictionaries: async () => (await readState()).dictionaries,
readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled),
version: chrome.runtime.getManifest().version,
});
// Snapshot of the last projection SubMiner received, so a later write can
// be reduced to the fields SubMiner actually changed.
let lastProjection = null;
async function optionsGetFull() {
const [state, options] = await Promise.all([readState(), readOptions()]);
const revisions = { dictionaries: state.revision, options: options.revision };
lastProjection = {
revisions,
enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])),
server: options.anki.url,
deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])),
};
return {
profileCurrent: 0,
hachidoriRevisions: revisions,
profiles: [{ name: 'Hachidori', options: {
scanning: { length: options.scanLength },
dictionaries: state.dictionaries.map((entry, index) => ({
name: entry.title, alias: entry.displayName || entry.title,
id: index, enabled: entry.enabled !== false,
})),
anki: {
server: options.anki.url,
cardFormats: options.anki.templates.map(template => ({
id: template.id, type: 'term', enabled: true, deck: template.deck,
})),
},
} }],
};
}
async function setAllSettings(value) {
const projected = value.profiles?.[0]?.options;
const revisions = value.hachidoriRevisions;
if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection');
const base = lastProjection
&& lastProjection.revisions.dictionaries === revisions.dictionaries
&& lastProjection.revisions.options === revisions.options ? lastProjection : null;
const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true]));
const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck]));
for (let attempt = 0; ; attempt += 1) {
const [state, options] = await Promise.all([readState(), readOptions()]);
const current = state.revision === revisions.dictionaries && options.revision === revisions.options;
if (!current && !base) {
throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action');
}
// Once Hachidori has moved on, apply only SubMiner's own changes on top
// of the newer settings instead of replaying the stale projection.
const enabledFor = title => enabledByTitle[title] === true;
const dictionaries = state.dictionaries.map(entry =>
current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title)
? { ...entry, enabled: enabledFor(entry.title) } : entry);
const templates = options.anki.templates.map(template => {
const deck = deckByFormat[template.id];
return deck !== undefined && (current || base.deckByFormat[template.id] !== deck)
? { ...template, deck } : template;
});
const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url;
const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck };
try {
if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) {
await engine('hd_apply_state', { baseRevision: state.revision, dictionaries });
}
if (JSON.stringify(anki) !== JSON.stringify(options.anki)) {
await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker');
}
return true;
} catch (error) {
// A write raced another Hachidori change; re-read once and reapply the delta.
if (attempt > 0 || !base) throw error;
}
}
}
async function getTermFrequencies({ termReadingList, dictionaries }) {
const terms = [...new Set(termReadingList.map(pair => pair.term))];
const { results } = await api({ type: 'hd_api_term_entries', terms });
const frequencies = [];
for (const result of results) {
const term = terms[result.index];
const pairs = termReadingList.filter(pair => pair.term === term);
for (const entry of result.dictionaryEntries) {
for (const value of entry.frequencies) {
const headword = entry.headwords[value.headwordIndex];
if (!headword || headword.term !== term || !dictionaries.includes(value.dictionary)) continue;
if (!pairs.some(pair => pair.reading === null || pair.reading === headword.reading)) continue;
// Upstream does not expose the frequency entry's original reading.
// Keep its API flag and associate the value with the matched headword.
frequencies.push({ term, reading: headword.reading || null,
hasReading: value.hasReading, dictionary: value.dictionary,
frequency: value.frequency, displayValue: value.displayValue,
displayValueParsed: value.displayValueParsed });
}
}
}
return frequencies;
}
// Hachidori's public tokenize API emits display furigana without headwords.
// SubMiner's fallback requires one group per token and a dictionary form.
async function parseText({ text, scanLength }) {
const content = [];
let position = 0;
while (position < text.length) {
const rest = text.slice(position);
const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength });
const result = reply.results[0];
if (!result?.matched || !rest.startsWith(result.matched)) {
const character = String.fromCodePoint(rest.codePointAt(0));
content.push([{ text: character, reading: '' }]);
position += character.length;
continue;
}
const term = result.term;
// Keep the inflected ending in the reading, just as the main scanner does.
let stem = 0;
while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1;
const ending = term.expression.slice(stem);
const reading = stem > 0 && term.reading.endsWith(ending)
? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem)
: term.reading;
content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]);
position += result.matched.length;
}
return [{ source: 'scanning-parser', index: 0, content }];
}
async function invoke(action, params) {
switch (action) {
case 'optionsGetFull': return optionsGetFull();
case 'setAllSettings': return setAllSettings(params.value);
case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({
title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode,
}));
case 'termsFind': {
const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] });
return reply.results[0];
}
case 'parseText': return parseText(params);
case 'getTermFrequencies': return getTermFrequencies(params);
default: throw new Error('Unsupported Hachidori parser action: ' + action);
}
}
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
};
async function importArchive(blob, fileName) {
const blobUrl = URL.createObjectURL(blob);
try {
const reply = await engine('hd_import', { blobUrl, fileName });
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
} finally {
URL.revokeObjectURL(blobUrl);
}
}
globalThis.__subminerYomitanSettingsAutomation = {
ready: true,
async importDictionaryArchiveUrl(url) {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the dictionary archive');
await importArchive(await response.blob(), 'subminer-dictionary.zip');
},
async importDictionaryArchiveBase64(base64, fileName) {
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName);
},
async deleteDictionary(title) {
const dictionary = (await readState()).dictionaries.find(entry => entry.title === title);
if (dictionary) await engine('hd_remove', { id: dictionary.id, title });
},
};
globalThis.__subminerAddNote = async word => {
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
const result = lookup.results[0];
if (!result) return { noteId: null, duplicateNoteIds: [] };
// Match the dictionary context supplied by Hachidori's popup to its
// native glossary, alias, and frequency template renderers.
const { dictionaries } = await readState();
const frequencyModes = new Map(dictionaries.map(entry => [entry.title, entry.frequencyMode]));
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
const status = await send('hd_anki_status', {}, 'hachidori-anki');
const request = {
...result, term, generation: lookup.generation, sentence: word, searchQuery: word,
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
configKey: status.configKey, subminerEnrich: false,
dictionaryAliases: Object.fromEntries(dictionaries.filter(entry => entry.displayName)
.map(entry => [entry.title, entry.displayName])),
dictionaryIds: Object.fromEntries(dictionaries.map(entry => [entry.title, entry.id])),
frequencyDictionaries: dictionaries.filter(entry => entry.enabled !== false && entry.frequencyCount > 0)
.map(entry => entry.title),
captureUnavailable: ['screenshot', 'animation', 'audio'],
};
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
if (preflight.canAdd !== true) {
if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable');
return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] };
}
const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki');
if (submitted.state === 'added' || submitted.state === 'updated') {
return { noteId: submitted.noteId, duplicateNoteIds: [] };
}
if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] };
throw new Error(submitted.error || 'Hachidori could not confirm the note write');
};
})();
`;
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import * as vm from 'node:vm';
import {
countTermsFindLookups,
createDeps,
@@ -23,6 +24,102 @@ import {
upsertYomitanDictionarySettings,
} from './yomitan-parser-runtime';
test('Yomitan restores direct Anki after disabling its managed proxy without a page helper', async () => {
const options = { profiles: [{ options: { anki: { server: 'http://127.0.0.1:8765' } } }] };
let managedUrl: string | null = null;
const context = vm.createContext({
chrome: {
storage: {
local: {
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
managedUrl = value.subminerAnkiProxyUrl;
},
},
},
runtime: {
sendMessage: (
message: { action: string },
callback: (response: { result: unknown }) => void,
) => callback({ result: message.action === 'optionsGetFull' ? options : null }),
},
},
});
const deps = createDeps(async (script) =>
structuredClone(await vm.runInContext(script, context)),
);
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(managedUrl, 'http://127.0.0.1:8766');
assert.equal(await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true);
assert.equal(options.profiles[0]?.options.anki.server, 'http://127.0.0.1:8765');
assert.equal(managedUrl, null);
});
for (const failure of ['optionsGetFull', 'setAllSettings', 'storageSet']) {
test(`Yomitan retries disabling its managed proxy after ${failure} fails`, async () => {
const proxyUrl = 'http://127.0.0.1:8766';
const directUrl = 'http://127.0.0.1:8765';
let options = { profiles: [{ options: { anki: { server: proxyUrl } } }] };
let managedUrl: string | null = proxyUrl;
let shouldFail = true;
const context = vm.createContext({
chrome: {
storage: {
local: {
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
if (shouldFail && failure === 'storageSet') throw new Error('Storage unavailable');
managedUrl = value.subminerAnkiProxyUrl;
},
},
},
runtime: {
sendMessage: (
message: { action: string; params?: { value: typeof options } },
callback: (response: { result?: unknown; error?: { message: string } }) => void,
) => {
if (shouldFail && message.action === failure) {
callback({ error: { message: 'Settings unavailable' } });
} else if (message.action === 'optionsGetFull') {
callback({ result: structuredClone(options) });
} else if (message.action === 'setAllSettings' && message.params) {
options = structuredClone(message.params.value);
callback({ result: null });
} else {
assert.fail(`Unexpected action: ${message.action}`);
}
},
},
},
});
const deps = createDeps(async (script) =>
structuredClone(await vm.runInContext(script, context)),
);
const errors: string[] = [];
const logger = { error: (message: string) => errors.push(message) };
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), false);
assert.equal(managedUrl, proxyUrl);
assert.equal(
options.profiles[0]?.options.anki.server,
failure === 'storageSet' ? directUrl : proxyUrl,
);
assert.equal(errors.length, 1);
shouldFail = false;
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), true);
assert.equal(options.profiles[0]?.options.anki.server, directUrl);
assert.equal(managedUrl, null);
assert.equal(errors.length, 1);
});
}
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
@@ -1,7 +1,19 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { AnkiConnectConfig } from '../../../types';
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import {
buildHachidoriSharingScript,
parseHachidoriHostStatus,
type HachidoriSharingRequest,
} from '../../../shared/hachidori-sharing';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import {
HACHIDORI_PARSER_BRIDGE_SCRIPT,
HACHIDORI_SESSION_PARTITION,
} from './hachidori-parser-bridge';
import { selectYomitanParseTokens } from './parser-selection-stage';
import {
buildYomitanScanCallScript,
@@ -477,7 +489,7 @@ async function requestYomitanProfileMetadata(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -596,6 +608,18 @@ function logYomitanProfileDiagnostics(
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
function isHachidoriExtension(extension: Extension): boolean {
return extension.name === 'Hachidori';
}
// Without an explicit session, use the one the extension was loaded into:
// Hachidori lives in its own partition, Yomitan in the default session.
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
return isHachidoriExtension(extension)
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
: electron.session.defaultSession;
}
async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
@@ -606,19 +630,20 @@ async function ensureYomitanParserWindow(
return false;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const existingInitPromise = deps.getYomitanParserInitPromise();
if (existingInitPromise) {
return existingInitPromise;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const initPromise = (async () => {
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession =
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const parserWindow = new BrowserWindow({
show: false,
width: 800,
@@ -649,11 +674,27 @@ async function ensureYomitanParserWindow(
});
try {
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) {
await readyPromise;
}
if (isHachidoriExtension(yomitanExt)) {
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
} else {
// did-finish-load precedes the search page's asynchronous backend initialization.
await parserWindow.webContents.executeJavaScript(
`(async () => {
const deadline = Date.now() + 10000;
while (typeof window.__subminerAddNote !== 'function') {
if (Date.now() >= deadline) throw new Error('Yomitan search page initialization timed out');
await new Promise(resolve => setTimeout(resolve, 50));
}
})()`,
true,
);
}
// Eagerly install the scan runtime so the first subtitle line does not
// pay the install round trip; failures fall back to the per-request
// install-and-retry path.
@@ -696,8 +737,8 @@ async function createYomitanExtensionWindow(
return null;
}
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const window = new BrowserWindow({
show: false,
width: 1200,
@@ -740,6 +781,10 @@ async function invokeYomitanSettingsAutomation<T>(
}
try {
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
}
await settingsWindow.webContents.executeJavaScript(
`
(async () => {
@@ -892,7 +937,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -922,7 +967,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1079,7 +1124,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1122,7 +1167,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1206,7 +1251,17 @@ function cacheFrequencyEntriesForPairs(
const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
// Untagged frequency rows apply to every reading. A term-only query must
// retain all readings, rather than selecting only its untagged rows.
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
frequencyCache.set(
key,
pair.reading === null
? termEntries
: exactEntries
? [...exactEntries, ...untaggedEntries]
: termEntries,
);
}
}
@@ -1340,6 +1395,7 @@ export async function syncYomitanDefaultAnkiServer(
options?: {
forceOverride?: boolean;
deck?: string;
ankiConfig?: AnkiConnectConfig;
},
): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim();
@@ -1359,7 +1415,7 @@ export async function syncYomitanDefaultAnkiServer(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1379,6 +1435,13 @@ export async function syncYomitanDefaultAnkiServer(
const targetServer = ${JSON.stringify(normalizedTargetServer)};
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
const forceOverride = ${forceOverride ? 'true' : 'false'};
const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)};
if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') {
return globalThis.__subminerSyncAnkiSettings({
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
});
}
const { subminerAnkiProxyUrl: previousManagedProxy } = await chrome.storage.local.get('subminerAnkiProxyUrl');
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
@@ -1405,8 +1468,11 @@ export async function syncYomitanDefaultAnkiServer(
let changed = false;
if (currentServer !== targetServer) {
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
if (!canReplaceCurrent) {
// A custom endpoint needs no settings change, but the proxy is no longer managed.
await chrome.storage.local.set({ subminerAnkiProxyUrl: null });
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
}
@@ -1444,17 +1510,28 @@ export async function syncYomitanDefaultAnkiServer(
}
}
if (changed) {
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
}
// Preserve the previous managed endpoint until settings are saved so failed switches can retry.
await chrome.storage.local.set({ subminerAnkiProxyUrl: forceOverride ? targetServer : null });
if (!changed) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
}
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
})();
`;
try {
const result = await parserWindow.webContents.executeJavaScript(script, true);
if (isObject(result) && result.pending === true) {
logger.info?.(
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
);
return false;
}
const updated =
typeof result === 'object' &&
result !== null &&
@@ -1564,7 +1641,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1586,6 +1663,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
`;
}
export async function requestHachidoriSharing(
request: HachidoriSharingRequest,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
) {
const extension = deps.getYomitanExt();
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
const ready = await ensureYomitanParserWindow(deps, logger);
const window = deps.getYomitanParserWindow();
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
const reply: unknown = await window.webContents.executeJavaScript(
buildHachidoriSharingScript(request),
true,
);
const status = parseHachidoriHostStatus(reply);
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
return status;
}
async function invokeYomitanBackendAction<T>(
action: string,
params: unknown,
@@ -1704,6 +1800,7 @@ export async function importYomitanDictionaryFromZip(
zipPath: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
hachidoriManagementUrl = '',
): Promise<boolean> {
const normalizedZipPath = zipPath.trim();
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
@@ -1711,6 +1808,30 @@ export async function importYomitanDictionaryFromZip(
return false;
}
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
try {
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
if (host.kind === 'disconnected' || host.kind === 'unavailable')
throw new Error(host.message);
if (host.kind === 'connected') {
await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl);
const window = deps.getYomitanParserWindow();
if (window) clearYomitanParserCachesForWindow(window);
logger.info?.(
`Uploaded character dictionary to Hachidori host: ${path.basename(normalizedZipPath)}`,
);
return true;
}
} catch (error) {
logger.error(
'Hachidori character dictionary import failed:',
error instanceof Error ? error.message : String(error),
);
return false;
}
}
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
`
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
// Bump whenever the install script below changes so already-loaded parser
// windows re-install the new scan runtime instead of running the stale one.
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
export interface YomitanScanRequestParams {
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
}
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -30,8 +30,17 @@ export function createDeps(
}
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
const storage: Record<string, unknown> = {};
return {
chrome: {
storage: {
local: {
get: async () => ({ ...storage }),
set: async (value: Record<string, unknown>) => {
Object.assign(storage, value);
},
},
},
runtime: {
lastError: null,
sendMessage: (
+3 -2
View File
@@ -16,6 +16,7 @@ type ExtensionCopyResult = {
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
extensionName?: 'yomitan' | 'hachidori';
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
@@ -156,7 +157,7 @@ export function ensureExtensionCopy(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy) {
@@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
@@ -188,6 +188,11 @@ export async function loadYomitanExtension(
deps.setYomitanSession(targetSession);
try {
if (!externalProfilePath) {
// Electron may retain old extension scripts after an update, across app restarts.
// Keep dictionaries/settings while ensuring the bundled worker loads current code.
await targetSession.clearStorageData({ storages: ['serviceworkers'] });
}
const extensions = targetSession.extensions;
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
+9 -5
View File
@@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
backend?: 'yomitan' | 'hachidori';
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
@@ -23,6 +24,7 @@ type HyprlandSessionEnv = {
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
backend?: 'yomitan' | 'hachidori';
}
export function shouldInstallYomitanSettingsCloseButton(
@@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate(
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
export function buildYomitanSettingsCloseButtonScript(
backend: 'yomitan' | 'hachidori' = 'yomitan',
): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
@@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string {
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
@@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton(
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
@@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
const settingsWindow = new ElectronBrowserWindow({
title: 'Yomitan Settings',
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
width: 1200,
height: 800,
show: false,
@@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
});
setTimeout(() => {
+195 -9
View File
@@ -1,3 +1,4 @@
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2026 sudacode
@@ -33,6 +34,14 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import {
createHachidoriExtensionRuntime,
getHachidoriSession,
} from './core/services/hachidori-extension';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
} from './shared/dictionary-external-link';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
@@ -891,14 +900,34 @@ const {
appState,
appLifecycleApp,
} = bootServices;
// Backend changes take effect on restart; windows and parser must share one session.
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
let hachidoriSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanExtension: Extension | null = null;
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
if (
activeDictionaryBackend !== 'hachidori' ||
event.senderFrame !== event.sender.mainFrame ||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
) {
throw new Error('Dictionary links are only available from the active overlay');
}
await shell.openExternal(parseDictionaryExternalUrl(value));
});
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
return [
appState.configSettingsWindow,
appState.syncUiWindow,
appState.yomitanSettingsWindow,
hachidoriSettingsWindow,
inactiveYomitanSettingsWindow,
appState.anilistSetupWindow,
appState.jellyfinSetupWindow,
appState.firstRunSetupWindow,
@@ -996,6 +1025,8 @@ const {
} = statsServerRuntime;
function requestAppQuit(): void {
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
@@ -1440,6 +1471,15 @@ const createCommandLineLauncherRuntimeOptions = () => ({
: undefined,
});
const firstRunSetupService = createFirstRunSetupService({
getDictionaryBackend: () => activeDictionaryBackend,
getHachidoriHostStatus: async () => {
await ensureYomitanExtensionLoaded();
return requestHachidoriSharing(
{ type: 'hd_sharing_status' },
getYomitanParserRuntimeDeps(),
logger,
);
},
platform: process.platform,
configDir: CONFIG_DIR,
getYomitanDictionaryCount: async () => {
@@ -2011,7 +2051,24 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null {
if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') {
return null;
}
return command[2] === 'yes' || command[2] === 'no' ? command[2] : null;
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
// behind an open timing review; the review applies them when it closes. A pause
// request during the review keeps playback paused after it closes.
const pauseValue = getMpvPauseCommandValue(rawCommand);
if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) {
return;
}
if (pauseValue === 'yes') {
mediaTimingReviewRuntime.cancelPlaybackResume();
}
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
@@ -2262,6 +2319,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
logger.error('Failed to auto-populate Hachidori Anki settings', error),
);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
@@ -2608,11 +2670,18 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
return false;
}
await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
return await importYomitanDictionaryFromZip(
zipPath,
getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
},
configService.getConfig().hachidori.externalHostManagementUrl,
);
},
dictionaryImportReplacesExisting: () =>
getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori',
deleteYomitanDictionary: async (dictionaryTitle) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
@@ -3437,6 +3506,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
dictionaryBackend: snapshot.dictionaryBackend,
hachidoriHost: snapshot.hachidoriHost,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
@@ -3452,6 +3523,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (
submission.action === 'link-hachidori-host' ||
submission.action === 'unlink-hachidori-host'
) {
try {
if (activeDictionaryBackend !== 'hachidori')
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
await ensureYomitanExtensionLoaded();
const status = await requestHachidoriSharing(
submission.action === 'link-hachidori-host'
? { type: 'hd_sharing_client_link', address: submission.address }
: { type: 'hd_sharing_client_unlink' },
getYomitanParserRuntimeDeps(),
logger,
);
firstRunSetupMessage =
status.kind === 'local'
? 'Using dictionaries installed in SubMiner.'
: status.kind === 'connected'
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
: status.message;
} catch (error) {
firstRunSetupMessage =
error instanceof Error ? error.message : 'Could not update the dictionary host.';
}
return;
}
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
firstRunSetupMessage = snapshot.message;
@@ -3492,8 +3590,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
firstRunSetupMessage = openDictionarySettings()
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
@@ -5153,6 +5251,41 @@ function initializeOverlayRuntime(): void {
}
function openYomitanSettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
return false;
}
inactiveYomitanLoad ??= inactiveYomitanExtension
? Promise.resolve(inactiveYomitanExtension)
: loadYomitanExtensionCore({
userDataPath: USER_DATA_PATH,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: (extension) => {
inactiveYomitanExtension = extension;
},
setYomitanSession: () => {},
}).finally(() => {
inactiveYomitanLoad = null;
});
void (
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
)
.then((extension) =>
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => inactiveYomitanSettingsWindow,
setWindow: (window) => {
inactiveYomitanSettingsWindow = window;
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
return true;
}
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn(
@@ -5165,6 +5298,39 @@ function openYomitanSettings(): boolean {
return true;
}
function openHachidoriSettings(): void {
void (
activeDictionaryBackend === 'hachidori'
? ensureYomitanExtensionLoaded()
: hachidoriExtensionRuntime.ensureLoaded()
)
.then((extension) =>
openYomitanSettingsWindow({
backend: 'hachidori',
yomitanExt: extension,
yomitanSession: getHachidoriSession(),
getExistingWindow: () => hachidoriSettingsWindow,
setWindow: (window) => {
hachidoriSettingsWindow = window;
},
onWindowClosed: () => {
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
}
function openDictionarySettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
openHachidoriSettings();
return true;
}
return openYomitanSettings();
}
const { exportLogsFromTray } = createLogExportTrayRuntime({
flushMpvLog: () => flushMpvLog(),
logInfo: (message) => logger.info(message),
@@ -5192,7 +5358,7 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
isDev,
getMainWindow: () => overlayManager.getMainWindow(),
}),
@@ -5703,7 +5869,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
@@ -6150,6 +6316,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6325,7 +6492,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => appState.yomitanSession,
getYomitanSession: () =>
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
@@ -6413,6 +6581,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
getDictionaryBackend: () => activeDictionaryBackend,
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6457,12 +6627,28 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
});
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
externalProfilePath:
activeDictionaryBackend === 'yomitan'
? configService.getConfig().yomitan.externalProfilePath
: '',
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore,
loadYomitanExtensionCore: async (deps) => {
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
try {
const extension = await hachidoriExtensionRuntime.ensureLoaded();
deps.setYomitanExtension(extension);
deps.setYomitanSession(getHachidoriSession());
return extension;
} catch (error) {
logger.error('Failed to load Hachidori extension:', error);
deps.setYomitanExtension(null);
deps.setYomitanSession(null);
return null;
}
},
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow,
+2
View File
@@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext {
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
ui: {
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
openHachidoriSettings: context.openHachidoriSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
+2
View File
@@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams {
ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
@@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps(
ui: {
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
openHachidoriSettings: params.ui.openHachidoriSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
+54 -2
View File
@@ -18,6 +18,7 @@ type MockWindow = {
contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>;
stopLoadingCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
};
@@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & {
destroy: () => void;
focus: () => void;
emitDidFinishLoad: () => void;
emitDidStopLoading: () => void;
emitReadyToShow: () => void;
once: (event: 'ready-to-show', cb: () => void) => void;
webContents: {
@@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & {
getURL: () => string;
send: (channel: string, payload?: unknown) => void;
isFocused: () => boolean;
once: (event: 'did-finish-load', cb: () => void) => void;
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
focus: () => void;
};
} {
@@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & {
contentReady: true,
documentLoaded: true,
loadCallbacks: [],
stopLoadingCallbacks: [],
readyToShowCallbacks: [],
};
const window = {
@@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & {
callback();
}
},
emitDidStopLoading: () => {
state.loading = false;
const callbacks = state.stopLoadingCallbacks.splice(0);
for (const callback of callbacks) {
callback();
}
},
emitReadyToShow: () => {
const callbacks = state.readyToShowCallbacks.splice(0);
for (const callback of callbacks) {
@@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & {
},
focused: false,
isFocused: () => state.webContentsFocused,
once: (_event: 'did-finish-load', cb: () => void) => {
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
if (event === 'did-stop-loading') {
state.stopLoadingCallbacks.push(cb);
return;
}
state.loadCallbacks.push(cb);
},
focus: () => {
@@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
// before it; only did-stop-loading sees the settled state.
const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false;
window.documentLoaded = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'r1' },
{
restoreOnModalClose: 'media-timing-review',
preferModalWindow: true,
},
),
true,
);
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.emitReadyToShow();
window.url = 'file:///overlay/index.html?layer=modal';
window.documentLoaded = true;
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
window.emitDidStopLoading();
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
});
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
const window = createMockWindow();
window.contentReady = false;
+5
View File
@@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService(
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
modalWindowPrimeListenersRegistered.add(modalWindow);
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
}
return true;
@@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService(
// A hidden macOS panel may not emit ready-to-show until it is presented. The
// renderer can safely receive IPC as soon as its document has finished loading.
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
// fire even earlier), so a window created for this send would drop the message
// without the did-stop-loading pass that follows once the load state settles.
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
deliver(() => isWindowLoadedForIpc(window));
};
@@ -31,6 +31,45 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
return { promise, resolve };
}
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
for (const succeeds of [true, false]) {
let imported = false;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: makeTempDir(),
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/replacement.zip',
revision: 'new',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
}),
getYomitanDictionaryInfo: async () => [
{ title: 'SubMiner Character Dictionary', revision: 'old' },
],
dictionaryImportReplacesExisting: () => true,
importYomitanDictionary: async () => {
imported = true;
return succeeds;
},
deleteYomitanDictionary: async () => {
assert.fail('must keep the old dictionary until replacement succeeds');
},
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
if (succeeds) await runtime.runSyncNow();
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
assert.equal(imported, true);
}
});
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
const userDataPath = makeTempDir();
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
waitForYomitanMutationReady?: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
dictionaryImportReplacesExisting?: () => boolean;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
const importTimeoutMs = resolveImportTimeoutMs(
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
);
if (existing !== null) {
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
await withTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
@@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => {
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
@@ -56,6 +56,7 @@ function createDeps() {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
+2
View File
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -134,6 +135,7 @@ export function createCliCommandContext(
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -6,6 +6,11 @@ import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
import {
createDefaultSetupState,
getSetupStatePath,
readSetupState,
} from '../../shared/setup-state';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -762,3 +768,145 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
]);
});
});
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
let dictionaryCount = 0;
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => dictionaryCount,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.dictionaryBackend, 'hachidori');
assert.equal(initial.state.dictionaryBackend, 'hachidori');
assert.equal(initial.canFinish, false);
assert.equal(initial.externalYomitanConfigured, false);
assert.equal(initial.state.status, 'incomplete');
dictionaryCount = 1;
const completed = await hachidori.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.dictionaryBackend, 'hachidori');
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
assert.equal(hachidori.isSetupCompleted(), true);
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
// Switching back never repeats setup for a backend that already finished.
const yomitanAgain = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const restored = await yomitanAgain.ensureSetupStateInitialized();
assert.equal(restored.state.status, 'completed');
assert.equal(restored.state.dictionaryBackend, 'yomitan');
assert.equal(yomitanAgain.isSetupCompleted(), true);
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
});
});
test('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
});
await yomitan.ensureSetupStateInitialized();
const reopened = await yomitan.markSetupInProgress();
assert.equal(reopened.state.status, 'in_progress');
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
assert.equal(yomitan.isSetupCompleted(), false);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const switched = await hachidori.ensureSetupStateInitialized();
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
const restored = await yomitan.getSetupStatus();
assert.equal(restored.state.status, 'completed');
});
});
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
fs.writeFileSync(
getSetupStatePath(configDir),
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.state.status, 'incomplete');
const stored = readSetupState(getSetupStatePath(configDir));
assert.equal(stored?.dictionaryBackend, 'hachidori');
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
});
});
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
await withTempDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 2,
};
const service = createFirstRunSetupService({
configDir: dir,
getDictionaryBackend: () => 'hachidori',
getHachidoriHostStatus: async () => host,
getYomitanDictionaryCount: async () => 7,
detectPluginInstalled: () => false,
});
let snapshot = await service.getSetupStatus();
assert.equal(snapshot.dictionaryCount, 2);
assert.equal(snapshot.canFinish, true);
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' };
snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.dictionaryCount, 0);
assert.equal(snapshot.state.status, 'incomplete');
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
host = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 0,
};
assert.equal((await service.getSetupStatus()).canFinish, false);
host = { kind: 'local' };
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
assert.equal((await service.getSetupStatus()).canFinish, true);
});
});
+101 -5
View File
@@ -1,7 +1,10 @@
import fs from 'node:fs';
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import {
createDefaultSetupState,
getDefaultConfigFilePaths,
getSetupStateDictionaryBackend,
hasCompletedSetupForBackend,
getSetupStatePath,
isSetupCompleted,
readSetupState,
@@ -11,6 +14,7 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type { DictionaryBackend } from '../../types/config';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
}
export interface SetupStatusSnapshot {
dictionaryBackend: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.show ||
args.hide ||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
}
export function getFirstRunSetupCompletionMessage(snapshot: {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (
snapshot.hachidoriHost?.kind === 'disconnected' ||
snapshot.hachidoriHost?.kind === 'unavailable'
) {
return snapshot.hachidoriHost.message;
}
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
return `Install at least one ${name} dictionary before finishing setup.`;
}
return null;
}
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
hachidoriHost?: HachidoriHostStatus;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
const externalYomitanConfigured =
deps.getDictionaryBackend?.() !== 'hachidori' &&
(deps.isExternalYomitanConfigured?.() ?? false);
const hachidoriHost =
deps.getDictionaryBackend?.() === 'hachidori'
? await deps.getHachidoriHostStatus?.().catch(
(error: unknown): HachidoriHostStatus => ({
kind: 'unavailable',
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
}),
)
: undefined;
if (hachidoriHost && hachidoriHost.kind !== 'local') {
return {
configReady,
externalYomitanConfigured: false,
hachidoriHost,
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
};
}
if (configReady && externalYomitanConfigured) {
return {
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
return {
configReady,
hachidoriHost,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
detectLegacyMpvPluginCandidates?: () =>
@@ -283,8 +328,47 @@ export function createFirstRunSetupService(deps: {
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
const readStoredState = (): SetupState =>
readSetupState(setupStatePath) ?? createDefaultSetupState();
// The file records one backend's status at a time. Project it onto the active
// backend: a backend that finished before stays completed, any other stays
// incomplete until its own dictionaries are ready.
const projectState = (stored: SetupState): SetupState => {
const backend = getDictionaryBackend();
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
// Legacy files carry their completion only as the recorded status; keep it.
const storedBackend = getSetupStateDictionaryBackend(stored);
const completedDictionaryBackends = [
...new Set([
...(stored.completedDictionaryBackends ?? []),
...(stored.status === 'completed' ? [storedBackend] : []),
]),
];
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
return {
...stored,
dictionaryBackend: backend,
completedDictionaryBackends,
status: finishedBefore ? 'completed' : 'incomplete',
completedAt: finishedBefore ? stored.completedAt : null,
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
yomitanSetupMode: finishedBefore ? 'internal' : null,
lastSeenYomitanDictionaryCount: 0,
};
};
const readState = (): SetupState => projectState(readStoredState());
const writeState = (state: SetupState): SetupState => {
const backend = getDictionaryBackend();
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
state = {
...state,
dictionaryBackend: backend,
completedDictionaryBackends:
state.status === 'completed'
? [...others, backend]
: (state.completedDictionaryBackends ?? []),
};
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
@@ -292,10 +376,12 @@ export function createFirstRunSetupService(deps: {
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const { configReady, dictionaryCount, externalYomitanConfigured } =
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
@@ -314,6 +400,8 @@ export function createFirstRunSetupService(deps: {
installedWindowsMpvShortcuts,
);
return {
dictionaryBackend: getDictionaryBackend(),
hachidoriHost,
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
@@ -353,11 +441,19 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
const stored = readStoredState();
// Persist the active backend stamp so the launcher can tell which backend
// the running app gates playback on.
const state =
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
? stored
: writeState(projectState(stored));
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({
@@ -779,3 +779,51 @@ test('closing completed first-run setup quits app when completion policy allows
assert.deepEqual(calls, ['set', 'clear', 'quit']);
});
test('Hachidori setup names the active dictionary backend', () => {
const html = buildFirstRunSetupHtml({
dictionaryBackend: 'hachidori',
configReady: true,
dictionaryCount: 0,
canFinish: false,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
assert.match(html, /Hachidori dictionaries/);
assert.match(html, /Open Hachidori Settings/);
assert.match(html, /Install at least one Hachidori dictionary/);
assert.doesNotMatch(html, /Open Yomitan Settings/);
assert.match(html, /Link host/);
assert.match(html, /<details class="external-host">/);
assert.match(html, /Use an external dictionary host/);
assert.match(html, /keep the browser and its sharing relay running/);
assert.match(html, /You can close its browser management page/);
});
test('setup parses link and unlink actions without losing the host address', () => {
const address = 'ws://127.0.0.1:38771/link';
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=link-hachidori-host&address=' +
encodeURIComponent(address),
),
{ action: 'link-hachidori-host', address },
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'),
{ action: 'unlink-hachidori-host' },
);
});
+81 -14
View File
@@ -1,3 +1,5 @@
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import type { DictionaryBackend } from '../../types/config';
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
@@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
close: () => void;
};
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
export type FirstRunSetupSubmission =
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
| {
action: 'configure-windows-mpv-shortcuts';
startMenuEnabled: boolean;
desktopEnabled: boolean;
}
| { action: 'link-hachidori-host'; address: string }
| {
action:
| 'unlink-hachidori-host'
| 'remove-legacy-plugin'
| 'configure-windows-mpv-shortcuts'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'open-config-settings'
| 'refresh'
| 'finish';
};
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
mpvExecutablePath?: string;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
export interface FirstRunSetupHtmlModel {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
@@ -249,7 +257,45 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>`
: '';
const yomitanMeta = model.externalYomitanConfigured
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
const host = model.hachidoriHost;
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
const hostAddress = linked ? host.address : '';
const hostCard =
model.dictionaryBackend === 'hachidori'
? `
<div class="card block">
<strong>Dictionary source</strong>
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
<details class="external-host"${linked ? ' open' : ''}>
<summary>Use an external dictionary host</summary>
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
<ul class="meta host-requirements">
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
</ul>
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&amp;address='+encodeURIComponent(address)">
<label for="hachidori-host-address">Host address</label>
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
<div class="inline-actions">
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
</div>
</form>
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
</details>
</div>`
: '';
const yomitanMeta =
host?.kind === 'connected'
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
: host?.kind === 'disconnected'
? `Host unavailable: ${host.address}`
: model.externalYomitanConfigured
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
: `${model.dictionaryCount} installed`;
const yomitanBadgeLabel = model.externalYomitanConfigured
@@ -268,8 +314,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.canFinish
? model.externalYomitanConfigured
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
return `<!doctype html>
<html>
@@ -338,6 +384,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
color: var(--muted);
font-size: 12px;
}
.external-host summary {
cursor: pointer;
color: var(--blue);
font-weight: 700;
}
.external-host[open] summary {
margin-bottom: 10px;
}
.host-requirements {
padding-left: 20px;
margin: 0 0 12px;
}
.host-requirements li + li {
margin-top: 4px;
}
.shortcut-form {
display: grid;
gap: 8px;
@@ -466,17 +527,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<strong>${dictionaryName} dictionaries</strong>
<div class="meta">${escapeHtml(yomitanMeta)}</div>
</div>
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${hostCard}
${mpvExecutablePathCard}
${windowsShortcutCard}
${renderCommandLineLauncherSection(model.commandLineLauncher)}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
@@ -495,6 +557,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'link-hachidori-host' &&
action !== 'unlink-hachidori-host' &&
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
@@ -507,6 +571,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
) {
return null;
}
if (action === 'link-hachidori-host') {
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
}
if (action === 'configure-mpv-executable-path') {
return {
action,
+8 -1
View File
@@ -372,12 +372,19 @@ test('Windows stages a new runtime version while the prior Bun executable is run
await exited;
}
}
// Windows can keep bun.exe locked briefly after the exit event; cleanup
// skips locked runtimes, so retry the way the next launch would.
const firstDirectory = path.dirname(first.bunPath);
for (let attempt = 0; attempt < 30; attempt += 1) {
cleanupOldWindowsManagedRuntimes({
platform: 'win32',
localAppData: root,
appVersion: '2.0.0',
});
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
if (!fs.existsSync(firstDirectory)) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.equal(fs.existsSync(firstDirectory), false);
assert.ok(fs.existsSync(expectedSecond.bunPath));
});
+47 -3
View File
@@ -95,9 +95,11 @@ async function startActiveMediaTimingReview(
decisionTimeoutMs?: number;
generateWaveform?: () => Promise<number[]>;
play?: () => Promise<void>;
paused?: boolean;
} = {},
) {
const previewCalls: Array<[number, number]> = [];
const commands: Array<Array<string | number>> = [];
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
publishPayload = resolve;
@@ -106,8 +108,9 @@ async function startActiveMediaTimingReview(
getMpvClient: () => ({
connected: true,
currentVideoPath: '/video/show.mkv',
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
send: () => undefined,
requestProperty: async (name) =>
name === 'duration' ? 100 : name === 'pause' ? (options.paused ?? true) : null,
send: ({ command }) => commands.push(command),
}),
getCurrentMediaPath: () => '/video/show.mkv',
getMpvExecutablePath: () => 'mpv',
@@ -138,7 +141,7 @@ async function startActiveMediaTimingReview(
maxMediaDuration: options.maxMediaDuration ?? 30,
});
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
return { runtime, payload: await openedPayload, pendingDecision, previewCalls, commands };
}
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
@@ -642,6 +645,47 @@ test('collectMediaTimingContextLines falls back to played history when no cues a
assert.deepEqual(context.next, []);
});
test('media timing review keeps an already-paused video paused when nothing asks to resume', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
test('media timing review holds overlay resume requests until the review closes', async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview();
assert.equal(runtime.deferPlaybackResume(), true);
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'pause', 'no'],
]);
assert.equal(runtime.deferPlaybackResume(), false);
});
for (const paused of [true, false]) {
test(`media timing review stays paused after an overlay pause request (paused before: ${paused})`, async () => {
const { runtime, payload, pendingDecision, commands } = await startActiveMediaTimingReview({
paused,
});
assert.equal(runtime.deferPlaybackResume(), true);
runtime.cancelPlaybackResume();
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
await pendingDecision;
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
});
}
test('media timing review watchdog falls back when the renderer stops responding', async () => {
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
+35 -1
View File
@@ -283,6 +283,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
let active: ActiveReview | null = null;
let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
let resumeDeferred = false;
function restorePendingPlayback(): void {
const mpvClient = pendingPauseRestore;
@@ -292,6 +293,35 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
}
function resumeDeferredPlayback(): void {
if (!resumeDeferred) return;
resumeDeferred = false;
const mpvClient = deps.getMpvClient();
if (mpvClient?.connected) {
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
/**
* Holds an overlay request to resume playback (e.g. an auto-pause released because the
* dictionary popup closed) until the pending review ends, then applies it. Returns false
* when no review holds playback, so the caller should resume right away.
*/
function deferPlaybackResume(): boolean {
if (!active && !currentRequest) return false;
resumeDeferred = true;
return true;
}
/**
* The overlay asked to pause while a review holds playback, so closing the review must
* not resume it: drops both a held overlay resume and the review's own restore.
*/
function cancelPlaybackResume(): void {
resumeDeferred = false;
if (active) active.restorePlayback = false;
}
function ensureWindow(
review: ActiveReview,
range: RemoteMediaWindowRange,
@@ -539,6 +569,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
} finally {
if (currentRequest === lifecycle) {
currentRequest = null;
resumeDeferredPlayback();
}
lifecycle.markSettled();
}
@@ -737,7 +768,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
if (!current) return;
deps.clearFrameCache?.();
void current.preview?.session.then((session) => session.dispose()).catch(() => {});
if (current.restorePlayback && current.mpvClient.connected) {
if ((current.restorePlayback || resumeDeferred) && current.mpvClient.connected) {
resumeDeferred = false;
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
}
}
@@ -758,6 +790,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
getFrame,
stopPreview,
resolveReview,
deferPlaybackResume,
cancelPlaybackResume,
dispose,
};
}
@@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w
);
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
});
for (const flag of ['--yomitan', '--hachidori']) {
test(`${flag} settings startup skips heavy startup`, () => {
assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true);
assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false);
});
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
shouldRunYomitanOnlyStartup,
shouldRunDictionarySettingsOnlyStartup,
} from '../../cli/args';
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
@@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunYomitanOnlyStartup(initialArgs) ||
(shouldRunDictionarySettingsOnlyStartup(initialArgs) ||
initialArgs.settings ||
initialArgs.stats ||
initialArgs.dictionary ||
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import * as vm from 'node:vm';
import { createDeps } from '../../core/services/tokenizer/yomitan-scan-test-harness';
import { DEFAULT_CONFIG } from '../../config';
import { ImmersionTrackerService } from '../../core/services/immersion-tracker-service';
import { createAnilistRateLimiter } from '../../core/services/anilist/rate-limiter';
@@ -34,6 +36,7 @@ function createDeferred<T>() {
function createRuntimeHarness(
startServer: NonNullable<StatsServerRuntimeDeps['startServer']>,
backgroundState: BackgroundStatsServerState | null = null,
overrides: Partial<StatsServerRuntimeDeps> = {},
) {
const appStateValues: Array<StatsServer | null> = [];
const tracker = new ImmersionTrackerService({ dbPath: ':memory:' });
@@ -69,10 +72,54 @@ function createRuntimeHarness(
removeBackgroundStatsServerState: () => {},
isBackgroundStatsServerProcessAlive: () => false,
startServer,
...overrides,
});
return { runtime, appStateValues };
}
test('dashboard word mining keeps the configured proxy as Hachidori Anki endpoint', async () => {
const settings: unknown[] = [];
const context = vm.createContext({
chrome: {},
__subminerSyncAnkiSettings: async (value: unknown) => {
settings.push(value);
return { updated: true, matched: true };
},
__subminerAddNote: async () => ({ noteId: 123, duplicateNoteIds: [] }),
});
vm.runInContext('window = globalThis', context);
const configs: Parameters<NonNullable<StatsServerRuntimeDeps['startServer']>>[0][] = [];
const { runtime } = createRuntimeHarness(
async (config) => {
configs.push(config);
return { close: async () => {} };
},
null,
{
...createDeps(async (script) => structuredClone(await vm.runInContext(script, context))),
getResolvedConfig: () => ({
...DEFAULT_CONFIG,
ankiConnect: {
...DEFAULT_CONFIG.ankiConnect,
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
...DEFAULT_CONFIG.ankiConnect.proxy,
enabled: true,
host: '127.0.0.1',
port: 8766,
},
},
}),
},
);
await runtime.ensureStatsServerStarted();
assert.equal(await configs[0]?.addYomitanNote?.('入れる'), 123);
assert.ok(settings[0] && typeof settings[0] === 'object' && 'server' in settings[0]);
assert.equal(settings[0].server, 'http://127.0.0.1:8766');
await runtime.stopStatsServer();
});
test('detects self-owned background stats daemon state', () => {
assert.equal(
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
+6 -2
View File
@@ -19,7 +19,10 @@ import {
writeBackgroundStatsServerState,
} from './stats-daemon';
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
export function isSelfOwnedBackgroundStatsDaemonState(state: {
pid: number;
@@ -171,10 +174,11 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
generateSentenceFurigana(text, highlightedText, yomitanDeps, yomitanLogger),
addYomitanNote: async (word: string) => {
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
const ankiUrl = getPreferredYomitanAnkiServerUrl(ankiConnectConfig);
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
deck: ankiConnectConfig.deck,
ankiConfig: ankiConnectConfig,
});
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
if (result.noteId && result.duplicateNoteIds.length > 0) {

Some files were not shown because too many files have changed in this diff Show More