diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 501855eb..04f6861e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,10 @@ jobs: - name: End-to-end suite run: bun run test:e2e + - name: Dictionary backend windows (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a bun run test:dictionary:electron + - name: Upload session logs (on failure) if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index fbba9131..af0fe6c8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ coverage/ /main-entry.js /main-entry.js.map +/build/hachidori/ + # Logs *.log npm-debug.log* diff --git a/.gitmodules b/.gitmodules index 860a00c3..e8965564 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md index d48e661d..4740f65d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # SubMiner -Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv +Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv [Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe) @@ -25,6 +25,8 @@ Look up words with Yomitan, mine them to Anki, and track your immersion without Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install. +Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki. +
Yomitan dictionary popup over annotated subtitles in mpv
@@ -265,7 +267,8 @@ SubMiner builds on the work of these open-source projects: | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | -| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser | +| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser | +| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts | | [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | ## License diff --git a/bun.lock b/bun.lock index 4dd16633..01b147e0 100644 --- a/bun.lock +++ b/bun.lock @@ -351,7 +351,7 @@ "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], - "follow-redirects": ["follow-redirects@1.16.1", "", {}, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="], + "follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="], "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], diff --git a/changes/fix-timing-review-playback-hold.md b/changes/fix-timing-review-playback-hold.md new file mode 100644 index 00000000..96fc820a --- /dev/null +++ b/changes/fix-timing-review-playback-hold.md @@ -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. diff --git a/changes/hachidori-backend.md b/changes/hachidori-backend.md new file mode 100644 index 00000000..34e0b727 --- /dev/null +++ b/changes/hachidori-backend.md @@ -0,0 +1,15 @@ +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. Character dictionaries reach a linked host over the link (Hachidori 0.2.3 or later, or a current Docker host), retry busy imports, and replace the previous dictionary only after a successful import. Older hosts report where to import the dictionary manually. +- Hachidori auto-populates its first Anki template from SubMiner's deck, tags (kept alongside its own `hachidori` tag), 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. +- Character dictionary imports report completion as soon as the archive is read instead of waiting out the temporary server's keep-alive timeout. +- 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. diff --git a/changes/hachidori-setup-docs.md b/changes/hachidori-setup-docs.md new file mode 100644 index 00000000..30697ac0 --- /dev/null +++ b/changes/hachidori-setup-docs.md @@ -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. diff --git a/changes/macos-login-shell-path.md b/changes/macos-login-shell-path.md new file mode 100644 index 00000000..d74b8069 --- /dev/null +++ b/changes/macos-login-shell-path.md @@ -0,0 +1,4 @@ +type: fixed +area: setup + +- Fixed the command-line launcher showing "Not on PATH" on macOS when SubMiner was opened from Finder or the Dock, even though the launcher directory was already on your terminal PATH. diff --git a/changes/overlay-modal-first-open.md b/changes/overlay-modal-first-open.md new file mode 100644 index 00000000..88adb937 --- /dev/null +++ b/changes/overlay-modal-first-open.md @@ -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. diff --git a/changes/patched-electron-undici-follow-redirects.md b/changes/patched-electron-undici-follow-redirects.md new file mode 100644 index 00000000..11954c4e --- /dev/null +++ b/changes/patched-electron-undici-follow-redirects.md @@ -0,0 +1,4 @@ +type: fixed +area: dependencies + +- Updated Electron to 43.8.3 and undici to 7.30.1, and pinned follow-redirects to 1.16.1, to resolve new security advisories. diff --git a/changes/prefetch-startup-restarts.md b/changes/prefetch-startup-restarts.md new file mode 100644 index 00000000..dc8c5d5c --- /dev/null +++ b/changes/prefetch-startup-restarts.md @@ -0,0 +1,5 @@ +type: fixed +area: annotations + +- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay. +- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded. diff --git a/config.example.jsonc b/config.example.jsonc index 2c0eecb1..e895f90d 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -6,6 +6,13 @@ */ { + // ========================================== + // 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 + // ========================================== // Subtitle Selection // Select primary and secondary mpv subtitle tracks from the overlay. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index ddc24b23..23a04cbf 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -15,9 +15,9 @@ If you changed AnkiConnect's port, set `ankiConnect.url` to match. ## How cards get filled -When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways: +When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways: -- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it. +- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it. - **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds. Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead. @@ -42,6 +42,20 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki - **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`. - **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine. +- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio. + +### Hachidori settings from SubMiner + +With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings: + +- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck. +- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written. +- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set. +- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields. + +Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry. + +Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this. ### Proxy troubleshooting @@ -87,6 +101,8 @@ If cards are not getting filled: Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped. +Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold. + `fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio. `ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none. @@ -122,6 +138,8 @@ Media settings apply to the next card without a restart. With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review. +Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused. + The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end. During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry. @@ -208,6 +226,8 @@ For Senren, use the same keys under `isSenren`. | `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card | | `manual` | Show both cards, let you choose which to keep and preview the merge | +Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media. + The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself. | Key | Action | diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 3bbbe41c..3daf5c51 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -67,6 +67,12 @@ Everything else needs a restart. ## Core settings +### Dictionary backend + +`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection. + +Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation. + ### Logging Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index ad9c117b..ce281114 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -91,11 +91,13 @@ Searches the primary subtitle lines and titles in your history. **Search by head Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available: -- **Mine Word**: full Yomitan lookup for the word, plus sentence, audio, and image. +- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data. - **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types. - **Mine Audio**: an audio card with `IsAudioCard` set. -Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs Yomitan dictionaries. +Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs dictionaries in the selected backend. + +Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, SubMiner uses it to keep the chosen history line when polling picks up the card. ## Repeated lines diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 2c0eecb1..e895f90d 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -6,6 +6,13 @@ */ { + // ========================================== + // 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 + // ========================================== // Subtitle Selection // Select primary and secondary mpv subtitle tracks from the overlay. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index cefa4d68..ed0466c1 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -6,12 +6,12 @@ Shortcuts work when the overlay has focus. With the [mpv plugin](/mpv-plugin), ` ## Global -| Shortcut | Action | Config key | -| ------------- | ---------------------- | -------------------------------------- | -| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` | -| `Alt+Shift+Y` | Open Yomitan settings | Fixed | +| Shortcut | Action | Config key | +| ------------- | ------------------------------- | -------------------------------------- | +| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` | +| `Alt+Shift+Y` | Open active dictionary settings | Fixed | -`Alt+Shift+Y` is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it. +`Alt+Shift+Y` opens Yomitan or Hachidori settings, whichever backend is running. It is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it. ## Mining @@ -98,17 +98,17 @@ The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Be Press `y`, then the second key. -| Keys | Action | -| ----- | -------------------------- | -| `y-y` | Open the SubMiner menu | -| `y-s` | Start the overlay | -| `y-S` | Stop the overlay | -| `y-t` | Toggle the visible overlay | -| `y-o` | Open Yomitan settings | -| `y-r` | Restart the overlay | -| `y-c` | Show overlay status | -| `y-h` | Open session help | -| `v` | Cycle primary subtitle bar | +| Keys | Action | +| ----- | ------------------------------- | +| `y-y` | Open the SubMiner menu | +| `y-s` | Start the overlay | +| `y-S` | Stop the overlay | +| `y-t` | Toggle the visible overlay | +| `y-o` | Open active dictionary settings | +| `y-r` | Restart the overlay | +| `y-c` | Show overlay status | +| `y-h` | Open session help | +| `v` | Cycle primary subtitle bar | The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay has focus, `y` then `d` toggles DevTools. diff --git a/docs-site/usage.md b/docs-site/usage.md index b1500385..06df5556 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -16,10 +16,41 @@ Run `subminer` with no file to pick one from the current directory instead. See ### Yomitan setup -Lookups need at least one dictionary in SubMiner's bundled Yomitan. First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`. +Lookups need at least one dictionary in SubMiner's bundled Yomitan (or in Hachidori, if you [switched backends](#hachidori-setup)). First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`. The bundled Yomitan is separate from any Yomitan in your browser. It has its own dictionaries and settings. +### Hachidori setup + +Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back. + +Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes. + +Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected. + +Switching backends: + +- First-run setup asks for dictionaries the first time you switch to a backend. Switching back to a backend that already finished setup skips it. +- Until you restart, SubMiner keeps running the backend it started with. The launcher waits for that backend before playback and logs a restart reminder. +- `--yomitan` and `--hachidori` both work whichever backend is selected. Opening settings does not switch backends. +- When `yomitan.externalProfilePath` is set, `--yomitan` is disabled to keep the external profile read-only. Hachidori settings still open. + +#### External dictionary host + +First-run setup can link a Hachidori host instead of using local dictionaries: **Dictionary source → Use an external dictionary host → Link host**. Turn on sharing in the other Hachidori app or browser, or start a compatible Docker host, then enter its sharing address, for example `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port. + +| Host | Must be running | +| -------- | ------------------------------------------- | +| Browser | The browser, the Hachidori extension, relay | +| Electron | The host app and any relay it needs | +| Docker | The container only | + +Setup checks the connection and the host's dictionaries before **Finish** unlocks, so import at least one dictionary on the host and refresh. The link survives restarts. **Unlink and use local dictionaries** goes back to local. + +While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them. + +SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. The host needs Hachidori 0.2.3 or later, or a current Docker host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings. + ## Picking files ```bash @@ -33,15 +64,15 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta ## Overlay basics -| Key | Action | -| ------------- | --------------------------------------------------------------------- | -| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) | -| `Alt+Shift+Y` | Open Yomitan settings (works from any window, not configurable) | -| `V` | Cycle the subtitle bar through hidden, visible, and hover-only | -| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes | -| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session | +| Key | Action | +| ------------- | ---------------------------------------------------------------------------- | +| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) | +| `Alt+Shift+Y` | Open Yomitan or Hachidori settings (works from any window, not configurable) | +| `V` | Cycle the subtitle bar through hidden, visible, and hover-only | +| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes | +| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session | -Hovering subtitle text pauses mpv, and moving away resumes it. An open Yomitan popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`. +Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`. You can drop files onto the overlay: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 63be066d..0392a434 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -46,6 +46,12 @@ Update checks and startup launcher migration share a serialized update-state sto ## Architecture Intent +The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend. + +`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart. + +`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise `linked-import-v1` (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes. + - Small units, explicit boundaries - Composition over monoliths - Pure helpers where possible diff --git a/docs/workflow/verification.md b/docs/workflow/verification.md index a6773274..f46ddc5d 100644 --- a/docs/workflow/verification.md +++ b/docs/workflow/verification.md @@ -66,6 +66,7 @@ bun run docs:build Content checks run inside the electron-builder afterPack hook. See the [release guide](../RELEASING.md#package-contents-checks) for the installed-app verification checklist. +- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux (CI runs it in the Linux E2E job). 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 diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index 0b6e6693..4f9691eb 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -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: () => diff --git a/launcher/config.ts b/launcher/config.ts index 57a7f9a7..f6556447 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -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; } diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 8f0d3c9f..68044530 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -199,6 +199,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; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 30989502..f6a93cbc 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -87,6 +87,8 @@ function applyRootOptions(program: Command): void { .option('--log-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') diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 542329e4..5d214190 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -378,6 +378,11 @@ const forwardedAppArgsCases: Array<{ argv: () => ['settings'], expected: () => '--settings\n', }, + ...['--settings', '--yomitan', '--hachidori'].map((flag) => ({ + name: `launcher ${flag} forwards the matching app settings command`, + argv: () => [flag], + expected: () => `${flag}\n`, + })), { name: 'launcher youtube command forwards the YouTube browser flag and log level', argv: () => ['yt', '--log-level', 'debug'], @@ -562,3 +567,43 @@ test( }); }, ); + +test('external Yomitan profile remains available while a running app awaits a backend switch', () => { + withSandbox(({ env, writeConfigFile }) => { + writeConfigFile('config.jsonc', { + 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); + }); +}); diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index bb82fca7..8d9fe82d 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -339,3 +339,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); + }); +} diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index 489b70ec..13de6560 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -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'; /** An in-progress setup state; tests override only the fields that matter. */ function makeState(overrides: Partial = {}): SetupState { @@ -189,3 +193,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); +}); diff --git a/launcher/setup-gate.ts b/launcher/setup-gate.ts index c82ee39f..f862fd37 100644 --- a/launcher/setup-gate.ts +++ b/launcher/setup-gate.ts @@ -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; 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; 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; + warn?: (message: string) => void; +}): Promise { + 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; + warn?: (message: string) => void; isExternalYomitanConfigured?: () => boolean; hasLegacyMpvPlugin?: () => boolean; launchSetupApp: () => void; @@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: { pollIntervalMs: number; }): Promise { 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'; diff --git a/package.json b/package.json index 9b7cf1fd..ca138b9d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -83,7 +84,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" diff --git a/scripts/build-hachidori.mjs b/scripts/build-hachidori.mjs new file mode 100644 index 00000000..c6d4a388 --- /dev/null +++ b/scripts/build-hachidori.mjs @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = path.join(repoRoot, 'vendor', 'hachidori'); +const output = path.join(repoRoot, 'build', 'hachidori'); +const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8')); + +// Upstream commits the engine binaries. Verify the pinned bytes before staging +// so ordinary app builds need neither Emscripten nor network access. +for (const [file, expected] of Object.entries(provenance.artifacts)) { + const actual = createHash('sha256') + .update(fs.readFileSync(path.join(source, file))) + .digest('hex'); + if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`); +} +const extension = path.join(source, 'extension'); +const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8')); +for (const file of [ + manifest.options_page, + manifest.background.service_worker, + 'offscreen.html', + ...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]), +]) { + if (!fs.existsSync(path.join(extension, file))) + throw new Error(`Missing Hachidori asset: ${file}`); +} +fs.rmSync(output, { recursive: true, force: true }); +fs.mkdirSync(output, { recursive: true }); +fs.cpSync(extension, output, { recursive: true }); +// Host configuration belongs in the staged copy, leaving the fork usable in Chrome. +const hostConfiguration = { + 'overlay-mode.js': [ + ['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'], + ['customJavaScript: true,', 'customJavaScript: false,'], + ], + // Overlay hosts seed the lookup highlight off because their Anki screenshot is + // the see-through viewport. SubMiner captures media from mpv, and without the + // highlight the sidebar shows nothing for the word being looked up. + 'setup-state.js': [ + [ + 'lookupMode: "hover",\n sourceHighlightEnabled: false,', + 'lookupMode: "hover",\n sourceHighlightEnabled: true,', + ], + ], +}; +for (const [file, replacements] of Object.entries(hostConfiguration)) { + const filePath = path.join(output, file); + let text = fs.readFileSync(filePath, 'utf8'); + for (const [original, replacement] of replacements) { + if (!text.includes(original)) + throw new Error(`Hachidori host configuration changed upstream: ${original}`); + text = text.replace(original, replacement); + } + fs.writeFileSync(filePath, text); +} +manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts'); +// A fixed key pins the extension ID, so Hachidori's storage origin (dictionaries, +// settings, setup state) survives userData path changes. Electron otherwise +// derives the ID from the unpacked directory path. +manifest.key = + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA40zTTbhCbrKnDOqj17tosDChP60VXuuMjR0tFFR/kAet21jrYn0JKPzqqNpXMGaJsx1chnuMOgaP0zuOT+gv9DNVV83wGLhQL7cC4LCoGN648WqnE2jYqBPj6hdD5A/N3uPeKFRAHkiC9HKoj2Et6sYZFTqag79QqQkkUz17X2VpjuTGmTP3lvhyEwhJWQKzOU0k6dTn6fKB2ZSqTebpw/JS2G8jf58vHlQOcfLhIiolKCG9DfMuo11aoG1s3AsFmQmw9VVyp4wgyQ80UI4lyBdqboanJxEiZ211+HKWPEgtG6VML1+Uo59qWS3uBU1apxE1+7E51UBEHaTsxajFIQIDAQAB'; +fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); +for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) { + fs.copyFileSync(path.join(source, file), path.join(output, file)); +} +process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`); diff --git a/scripts/build-hachidori.test.ts b/scripts/build-hachidori.test.ts new file mode 100644 index 00000000..3332bbcb --- /dev/null +++ b/scripts/build-hachidori.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +test('Hachidori staging configures Electron without changing the fork source', async () => { + const files = ['overlay-mode.js', 'setup-state.js', 'manifest.json']; + const source = (file: string) => + new URL(`../vendor/hachidori/extension/${file}`, import.meta.url); + const before = files.map((file) => readFileSync(source(file), 'utf8')); + + execFileSync(process.execPath, [ + fileURLToPath(new URL('./build-hachidori.mjs', import.meta.url)), + ]); + + assert.deepEqual( + files.map((file) => readFileSync(source(file), 'utf8')), + before, + ); + const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href); + assert.equal(staged.OVERLAY_MODE, true); + assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false); + const original = await import(source('overlay-mode.js').href); + assert.equal(original.OVERLAY_MODE, false); + assert.equal(original.HOST_CAPABILITIES.customJavaScript, true); + // SubMiner mines media from mpv, not the overlay viewport, so the lookup + // highlight stays on for a first install. + const stagedSetup = await import( + new URL('../build/hachidori/setup-state.js', import.meta.url).href + ); + assert.equal(stagedSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, true); + const originalSetup = await import(source('setup-state.js').href); + assert.equal(originalSetup.OVERLAY_MODE_OPTIONS.sourceHighlightEnabled, false); + const manifest = JSON.parse( + readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'), + ); + const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8')); + assert.deepEqual(manifest, { + ...originalManifest, + permissions: originalManifest.permissions.filter( + (permission: string) => permission !== 'userScripts', + ), + key: manifest.key, + }); + // The ID keys Hachidori's stored dictionaries and settings; changing the key orphans them. + const digest = createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex'); + const extensionId = [...digest.slice(0, 32)] + .map((digit) => String.fromCharCode(97 + parseInt(digit, 16))) + .join(''); + assert.equal(extensionId, 'jpfgmfknblendgepdejlfhocdcnjjelj'); +}); diff --git a/scripts/check-dictionary-backends.cjs b/scripts/check-dictionary-backends.cjs new file mode 100644 index 00000000..fc189b7f --- /dev/null +++ b/scripts/check-dictionary-backends.cjs @@ -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); +}); diff --git a/scripts/check-hachidori-parser.cjs b/scripts/check-hachidori-parser.cjs new file mode 100644 index 00000000..50c5ad34 --- /dev/null +++ b/scripts/check-hachidori-parser.cjs @@ -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, 'hachidori']); + 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); + }); diff --git a/scripts/hachidori-anki-audio.test.ts b/scripts/hachidori-anki-audio.test.ts new file mode 100644 index 00000000..801feaa6 --- /dev/null +++ b/scripts/hachidori-anki-audio.test.ts @@ -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 = {}; + let initialAudio = ''; + let stored = false; + const gateway = createAnkiGateway({ + readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null), + fetch: async (_url: string, options: RequestInit) => { + const { action, params } = JSON.parse(String(options.body)); + events.push(action); + let result: unknown; + switch (action) { + case 'multi': + result = [ + { result: ['Default'], error: null }, + { result: ['Basic'], error: null }, + { result: ['Expression', 'ExpressionAudio'], error: null }, + ]; + break; + case 'canAddNotes': + result = [true]; + break; + case 'canAddNotesWithErrorDetail': + result = [{ canAdd: true, error: null }]; + break; + case 'getMediaFilesNames': + result = stored ? [filename] : []; + break; + case 'storeMediaFile': + stored = true; + result = filename; + break; + case 'addNote': + fields = { ...params.note.fields }; + initialAudio = fields.ExpressionAudio ?? ''; + if (initialAudio) assert.ok(stored, 'audio must exist before the note references it'); + result = 123; + break; + case 'notesInfo': + result = [ + { + noteId: 123, + fields: Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, { value }]), + ), + }, + ]; + break; + case 'updateNoteFields': + Object.assign(fields, params.note.fields); + result = null; + break; + default: + throw new Error(`Unexpected action: ${action}`); + } + return Response.json({ result, error: null }); + }, + }); + const service = createAnkiWorkerService({ + gateway, + readOptions: async () => ({ + anki: { + url: 'http://127.0.0.1:8766', + apiKey: '', + templates: [ + { + id: 'default', + name: 'Default', + deck: 'Default', + model: 'Basic', + tags: [], + fields: {}, + duplicateScope: 'model', + duplicateBehavior: 'prevent', + captureScreenshot: false, + fieldTemplates: { + Expression: { value: '{expression}', overwriteMode: 'overwrite' }, + ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' }, + }, + }, + ], + }, + audioSources: [ + { id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' }, + ], + experimental: {}, + }), + readDictionaries: async () => [], + engine: async () => ({ ready: true, loading: false, generation: 1 }), + offscreen: async (message: { type: string; audio?: string }) => { + if (message.type === 'hd_anki_audio') { + events.push('pronunciation'); + if (audioFails) throw new Error('No pronunciation available'); + return { filename, data: 'YXVkaW8=' }; + } + return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] }; + }, + duplicateIndex: { source: async () => null, recordWrite: async () => {} }, + }); + const status = await service.status(); + assert.equal(status.available, true, status.error); + const result = await service.submit({ + configKey: status.configKey, + generation: 1, + term: { expression: '猫', reading: 'ねこ' }, + }); + return { result, initialAudio, fields, events, filename }; +} + +test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => { + const value = await mine(); + assert.equal(value.result.state, 'added'); + assert.equal(value.initialAudio, `[sound:${value.filename}]`); + assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1); +}); + +test('direct Hachidori keeps deferred pronunciation', async () => { + const value = await mine({ proxy: false }); + assert.equal(value.initialAudio, ''); + assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`); +}); + +test('unavailable pronunciation remains a warning without a late audio write', async () => { + const value = await mine({ audioFails: true }); + assert.equal(value.result.state, 'added'); + assert.match(value.result.warnings.join(' '), /No pronunciation available/); + assert.equal(value.initialAudio, ''); + assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1); + assert.equal(value.events.includes('updateNoteFields'), false); +}); diff --git a/scripts/hachidori-host.test.ts b/scripts/hachidori-host.test.ts new file mode 100644 index 00000000..cda320a3 --- /dev/null +++ b/scripts/hachidori-host.test.ts @@ -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'); + `); +}); diff --git a/scripts/package-audit.cjs b/scripts/package-audit.cjs index 65b26c68..22ed41a6 100644 --- a/scripts/package-audit.cjs +++ b/scripts/package-audit.cjs @@ -26,6 +26,14 @@ const REQUIRED_APP_FILES = [ ]), ]; const REQUIRED_RESOURCES = [ + 'hachidori/manifest.json', + 'hachidori/settings.html', + 'hachidori/subminer-host.js', + 'hachidori/vendor/hoshidicts.wasm', + 'hachidori/vendor/hoshidicts-threaded.wasm', + 'hachidori/vendor/hoshidicts-threaded-idbfs.wasm', + 'hachidori/LICENSE', + 'hachidori/SOURCE.json', 'yomitan/manifest.json', 'yomitan/data/fonts/kanji-stroke-orders.ttf', 'yomitan/fonts/NotoSansJP-Regular.ttf', diff --git a/scripts/run-dictionary-settings-smoke.mjs b/scripts/run-dictionary-settings-smoke.mjs new file mode 100644 index 00000000..c2f84db7 --- /dev/null +++ b/scripts/run-dictionary-settings-smoke.mjs @@ -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 }); +} diff --git a/scripts/smoke-package.cjs b/scripts/smoke-package.cjs index 8eb49fb4..9ffede4e 100644 --- a/scripts/smoke-package.cjs +++ b/scripts/smoke-package.cjs @@ -22,39 +22,6 @@ const timeout = setTimeout(() => { app.exit(1); }, 60_000); -const STATIC_TYPES = { - '.html': 'text/html', - '.js': 'text/javascript', - '.css': 'text/css', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.json': 'application/json', -}; - -// The stats dashboard is served by the stats HTTP server in the app, so load it -// over loopback HTTP from the packaged stats/dist and treat missing static -// assets as failures. API routes are not part of this smoke and may 404. -function serveStatsDist(root, failedRequests) { - const server = http.createServer((req, res) => { - const pathname = new URL(req.url, 'http://127.0.0.1').pathname; - const relative = pathname === '/' ? 'index.html' : pathname.slice(1); - try { - const body = fs.readFileSync(path.join(root, relative)); - res.writeHead(200, { - 'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream', - }); - res.end(body); - } catch { - if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`); - res.writeHead(404).end(); - } - }); - server.listen(0, '127.0.0.1'); - return server; -} - async function smoke() { await app.whenReady(); const packagedRequire = createRequire(path.join(archive, 'package.json')); @@ -83,6 +50,10 @@ async function smoke() { { allowFileAccess: true }, ); assert(extension.id, 'Yomitan extension failed to load'); + const hachidori = await session + .fromPartition('persist:hachidori') + .extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true }); + assert(hachidori.id, 'Hachidori extension failed to load'); const failedRequests = []; session.defaultSession.webRequest.onErrorOccurred( { urls: ['file://*/*', 'http://127.0.0.1/*'] }, @@ -93,9 +64,7 @@ async function smoke() { failedRequests.push(`${details.url}: ${details.error}`); }, ); - const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests); - await once(statsServer, 'listening'); - for (const ui of ['renderer', 'settings', 'syncui', 'stats']) { + for (const ui of ['renderer', 'settings', 'syncui']) { const win = new BrowserWindow({ show: false, webPreferences: { @@ -104,25 +73,52 @@ async function smoke() { }, }); try { - if (ui === 'stats') { - await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`); - // Let in-flight font requests settle before the window goes away. - await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)'); - } else { - await win.loadFile(path.join(archive, `dist/${ui}/index.html`)); - const loaded = await win.webContents.executeJavaScript( - `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, - ); - assert(loaded, `${ui}: shared Japanese font failed to load`); - } + await win.loadFile(path.join(archive, `dist/${ui}/index.html`)); + const loaded = await win.webContents.executeJavaScript( + `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, + ); + assert(loaded, `${ui}: shared Japanese font failed to load`); } finally { win.destroy(); } } - statsServer.close(); + // The stats dashboard uses HTTP for both assets and API requests in the app. + const { ImmersionTrackerService } = packagedRequire( + './dist/core/services/immersion-tracker-service.js', + ); + const { createStatsApp, startNodeHttpServer } = packagedRequire( + './dist/core/services/stats-server.js', + ); + const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') }); + const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker }; + let statsHttp; + const statsServer = await startNodeHttpServer( + createStatsApp(tracker, statsConfig), + statsConfig, + (listener) => (statsHttp = http.createServer(listener)), + ); + const statsWindow = new BrowserWindow({ show: false }); + try { + const url = `http://127.0.0.1:${statsHttp.address().port}`; + session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => { + if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`); + }); + await statsWindow.loadURL(url); + // Let in-flight font requests settle before the window goes away. + await statsWindow.webContents.executeJavaScript('document.fonts.ready.then(() => true)'); + for (const endpoint of ['overview', 'sessions']) { + const response = await fetch(`${url}/api/stats/${endpoint}`); + assert.equal(response.status, 200, `Stats ${endpoint} request failed`); + await response.json(); + } + } finally { + statsWindow.destroy(); + await statsServer.close(); + tracker.destroy(); + } assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load'); console.log( - 'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.', + 'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.', ); } diff --git a/src/anki-integration.ts b/src/anki-integration.ts index ce8001c6..cb2327c6 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -1802,6 +1802,9 @@ export class AnkiIntegration { request: Omit, ): Promise { 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({ diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index c10b1e9c..8f811fae 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -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; diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index 1cae5f3a..91c25c1d 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -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) : 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(); } diff --git a/src/anki-integration/note-update-workflow.test.ts b/src/anki-integration/note-update-workflow.test.ts index abd6b32e..3af34dd2 100644 --- a/src/anki-integration/note-update-workflow.test.ts +++ b/src/anki-integration/note-update-workflow.test.ts @@ -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, @@ -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(); diff --git a/src/anki-integration/note-update-workflow.ts b/src/anki-integration/note-update-workflow.ts index ccb560c8..c7af3029 100644 --- a/src/anki-integration/note-update-workflow.ts +++ b/src/anki-integration/note-update-workflow.ts @@ -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; } @@ -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(); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 1a5b03d9..4f5e6143 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -8,6 +8,7 @@ import { isStandaloneTexthookerCommand, parseArgs, shouldRunYomitanOnlyStartup, + shouldRunDictionarySettingsOnlyStartup, shouldStartApp, } from './args'; @@ -476,3 +477,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(setup), true); assert.equal(shouldStartApp(setup), true); }); + +test('Hachidori settings starts the app without overlay or mpv prerequisites', () => { + const args = parseArgs(['--hachidori']); + assert.equal(args.hachidori, true); + assert.equal(args.yomitan, false); + assert.equal(hasExplicitCommand(args), true); + assert.equal(shouldStartApp(args), true); + assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true); + assert.equal(shouldRunYomitanOnlyStartup(args), false); + assert.equal(commandNeedsOverlayRuntime(args), false); + assert.equal(commandNeedsOverlayStartupPrereqs(args), false); + assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false); + assert.equal( + shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])), + false, + ); + assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts index 942c6642..da3579a2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -12,6 +12,7 @@ export interface CliArgs { toggleVisibleOverlay: boolean; togglePrimarySubtitleBar: boolean; yomitan: boolean; + hachidori: boolean; settings: boolean; syncWindow: boolean; youtubeBrowser: boolean; @@ -133,6 +134,7 @@ export function parseArgs(argv: string[]): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, youtubeBrowser: false, @@ -279,6 +281,7 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true; else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true; else if (arg === '--yomitan') args.yomitan = true; + else if (arg === '--hachidori') args.hachidori = true; else if (arg === '--settings') args.settings = true; else if (arg === '--sync-window') args.syncWindow = true; else if (arg === '--youtube-browser') args.youtubeBrowser = true; @@ -563,6 +566,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.yomitan || + args.hachidori || args.settings || args.syncWindow || args.youtubeBrowser || @@ -643,6 +647,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleVisibleOverlay && !args.togglePrimarySubtitleBar && !args.yomitan && + !args.hachidori && !args.settings && !args.syncWindow && !args.youtubeBrowser && @@ -716,6 +721,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleVisibleOverlay || args.togglePrimarySubtitleBar || args.yomitan || + args.hachidori || args.settings || args.syncWindow || args.youtubeBrowser || @@ -767,8 +773,12 @@ export function shouldStartApp(args: CliArgs): boolean { } export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { + return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args); +} + +export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean { return ( - args.yomitan && + (args.yomitan || args.hachidori) && !args.background && !args.start && !args.stop && diff --git a/src/cli/help.ts b/src/cli/help.ts index bfa39f19..b3223e76 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 diff --git a/src/config/definitions.ts b/src/config/definitions.ts index e5d9be11..7da11e6a 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -22,6 +22,7 @@ export type { } from './definitions/shared'; const { + dictionaryBackend, subtitlePosition, keybindings, websocket, @@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG; export const DEFAULT_CONFIG: ResolvedConfig = { subtitleSelection: { enabled: false }, subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG }, + dictionaryBackend, subtitlePosition, keybindings, websocket, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 5d87c82c..b92fd9c8 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config'; export const CORE_DEFAULT_CONFIG: Pick< ResolvedConfig, + | 'dictionaryBackend' | 'subtitlePosition' | 'keybindings' | 'websocket' @@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick< | 'notifications' | 'auto_start_overlay' > = { + dictionaryBackend: 'yomitan', subtitlePosition: { yPercent: 10 }, keybindings: [], websocket: { diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index d3a30b0d..1852f997 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry( ] as const; return [ + { + 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', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 8d7bbe19..ee68378e 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -1,6 +1,14 @@ 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: 'Subtitle Selection', description: ['Select primary and secondary mpv subtitle tracks from the overlay.'], diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 74ba482d..b0553031 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared'; export function applyCoreDomainConfig(context: ResolveContext): void { const { src, resolved, warn } = context; + 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; diff --git a/src/config/resolve/dictionary-backend.test.ts b/src/config/resolve/dictionary-backend.test.ts new file mode 100644 index 00000000..b23307dc --- /dev/null +++ b/src/config/resolve/dictionary-backend.test.ts @@ -0,0 +1,30 @@ +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'); + } +}); diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index f1c119df..38ffe31b 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -146,6 +146,7 @@ const SECTION_ORDER = new Map( 'Discord Rich Presence', 'Jellyfin', 'Texthooker', + 'Dictionary Lookup', 'Yomitan', 'Stats dashboard', 'Startup warmups', @@ -228,8 +229,8 @@ const LABEL_OVERRIDES: Record = { 'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager', 'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar', 'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles', - 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup', - 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup', + 'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup', + 'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup', 'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode', 'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode', 'subtitleStyle.css': 'CSS Declarations', @@ -270,7 +271,7 @@ const DESCRIPTION_OVERRIDES: Record = { 'subtitleSidebar.css': 'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.', 'subtitleStyle.primaryVisibleOnYomitanPopup': - 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.', + 'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.', 'websocket.enabled': 'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.', 'discordPresence.updateIntervalMs': @@ -334,6 +335,9 @@ function humanizePath(path: string): string { } function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } { + if (path === 'dictionaryBackend') { + return { category: 'integrations', section: 'Dictionary Lookup' }; + } if ( path === 'subtitleStyle.autoPauseVideoOnHover' || path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' || diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 9108b92f..d76f63b9 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -1831,6 +1831,7 @@ describe('stats server API routes', () => { }); assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual(sentNote(env.requests, 'addNote').tags, ['SubMiner', 'SubMiner::Stats']); assert.match( sentNote(env.requests, 'updateNoteFields').fields.Picture ?? '', /^$/, diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index a23ed18c..803822f8 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -6,6 +6,7 @@ import test from 'node:test'; import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js'; import { Database } from '../immersion-tracker/sqlite.js'; import { + applyPragmas, ensureSchema, getOrCreateAnimeRecord, getOrCreateVideoRecord, @@ -50,6 +51,7 @@ test('stripFilenameTags normalizes common media-title formats', () => { async function backfillMissingCoverBlob(): Promise { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-test.mkv', { canonicalTitle: 'Cover Fetcher Test', @@ -114,6 +116,7 @@ test( async function reuseCachedAnimeCoverArt(): Promise { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', { canonicalTitle: 'Shared Cover Show', @@ -203,6 +206,7 @@ function createJsonResponse(payload: unknown): Response { test('fetchIfMissing uses guessit primary title and season when available', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', { canonicalTitle: @@ -308,6 +312,7 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn test('fetchIfMissing falls back to internal parser when guessit throws', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-fallback-test.mkv', { canonicalTitle: 'School Vlog S01E01', @@ -372,6 +377,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async ( test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', { canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv', @@ -450,6 +456,7 @@ test('fetchIfMissing caches a no-match when the season cannot be resolved', asyn test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', { canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv', @@ -557,6 +564,7 @@ for (const linkedToAnilist of [false, true]) { test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', { canonicalTitle: 'Hanzawa Naoki - 01.mkv', @@ -674,6 +682,7 @@ for (const linkedToAnilist of [false, true]) { test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => { const dbPath = makeDbPath(); const db = new Database(dbPath); + applyPragmas(db); ensureSchema(db); const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', { canonicalTitle: 'Hanzawa Naoki - 02.mkv', diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 049a8856..e3cb65c4 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, youtubeBrowser: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 3e70dd12..f935ae4c 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggle: false, toggleVisibleOverlay: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, youtubeBrowser: false, @@ -129,6 +130,9 @@ function createDeps(overrides: Partial = {}) { togglePrimarySubtitleBar: () => { calls.push('togglePrimarySubtitleBar'); }, + openHachidoriSettingsDelayed: (delayMs) => { + calls.push(`openHachidoriSettingsDelayed:${delayMs}`); + }, openYomitanSettingsDelayed: (delayMs) => { calls.push(`openYomitanSettingsDelayed:${delayMs}`); }, @@ -980,3 +984,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); + }); +} diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index f89d15b1..d91e1bd9 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -43,6 +43,7 @@ export interface CliCommandServiceDeps { togglePrimarySubtitleBar: () => void; openFirstRunSetup: (force?: boolean) => void; openYomitanSettingsDelayed: (delayMs: number) => void; + openHachidoriSettingsDelayed: (delayMs: number) => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -167,6 +168,7 @@ interface MiningCliRuntime { interface UiCliRuntime { openFirstRunSetup: (force?: boolean) => void; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -273,6 +275,11 @@ export function createCliCommandDepsRuntime( options.ui.openYomitanSettings(); }, delayMs); }, + openHachidoriSettingsDelayed: (delayMs) => { + options.schedule(() => { + options.ui.openHachidoriSettings(); + }, delayMs); + }, openConfigSettingsWindow: options.ui.openConfigSettingsWindow, openSyncUiWindow: options.ui.openSyncUiWindow, openYoutubeBrowserWindow: options.ui.openYoutubeBrowserWindow, @@ -424,6 +431,8 @@ export function handleCliCommand( deps.logDebug('Opened first-run setup flow.'); } else if (args.yomitan) { deps.openYomitanSettingsDelayed(1000); + } else if (args.hachidori) { + deps.openHachidoriSettingsDelayed(1000); } else if (args.settings) { deps.openConfigSettingsWindow(); } else if (args.syncWindow) { diff --git a/src/core/services/hachidori-extension.test.ts b/src/core/services/hachidori-extension.test.ts new file mode 100644 index 00000000..10b71887 --- /dev/null +++ b/src/core/services/hachidori-extension.test.ts @@ -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 }); + } +}); diff --git a/src/core/services/hachidori-extension.ts b/src/core/services/hachidori-extension.ts new file mode 100644 index 00000000..5fde9c7d --- /dev/null +++ b/src/core/services/hachidori-extension.ts @@ -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 | null = null; + return { + getSession: getHachidoriSession, + ensureLoaded(): Promise { + 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; + }, + }; +} diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index ff8d8bcc..2d8e6f3e 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -15,6 +15,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, youtubeBrowser: false, diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts index bbe8353c..653125bc 100644 --- a/src/core/services/stats-server/mining-routes.ts +++ b/src/core/services/stats-server/mining-routes.ts @@ -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 { widenHalfwidthKana } from '../../utils/halfwidth-kana.js'; import { resolveRetimedSecondarySubtitleTextFromSidecar, @@ -372,7 +373,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, diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index ad2c9f8c..c428f07d 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -300,3 +300,65 @@ test('prefetch service deduplicates repeated cue text within a run', async () => ); assert.ok(tokenizedTexts.includes('other')); }); + +test('prefetch service keeps an in-flight result from a superseded run', async () => { + const cues = makeCues(5); + const cachedTexts: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + if (text === 'line-0') await firstGate; + return { text, tokens: [] }; + }, + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + getCacheGeneration: () => 0, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + service.stop(); + releaseFirst(); + await flushMicrotasks(); + + assert.deepEqual(cachedTexts, ['line-0']); +}); + +test('prefetch service drops an in-flight result after cache invalidation', async () => { + const cues = makeCues(5); + const cachedTexts: string[] = []; + let generation = 0; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + if (text === 'line-0') await firstGate; + return { text, tokens: [] }; + }, + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + getCacheGeneration: () => generation, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + service.stop(); + generation += 1; + releaseFirst(); + await flushMicrotasks(); + + assert.deepEqual(cachedTexts, []); +}); diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index fd169f87..a532a9ba 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -7,6 +7,12 @@ export interface SubtitlePrefetchServiceDeps { tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; hasCachedTokenization?: (text: string) => boolean; + /** + * Bumped whenever the tokenization cache is invalidated. A result is cached only if + * the generation is unchanged since its tokenization began; without this dep every + * result counts as fresh. + */ + getCacheGeneration?: () => number; priorityWindowSize?: number; } @@ -88,9 +94,12 @@ export function createSubtitlePrefetchService( } warmedKeys.add(cacheKey); + // Freshness follows the cache generation, not the run: a seek or restart that + // supersedes this run leaves the result valid, and the parser time is already spent. + const generation = deps.getCacheGeneration?.(); try { const result = await deps.tokenizeSubtitle(cue.text); - if (result && !stopped && runId === currentRunId) { + if (result && generation === deps.getCacheGeneration?.()) { deps.preCacheTokenization(cue.text, result); } } catch { diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index d03d8c88..27710146 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -43,6 +43,8 @@ export interface SubtitleProcessingController { */ notePlainSubtitleEmitted: (text: string) => void; invalidateTokenizationCache: () => void; + /** Incremented by invalidateTokenizationCache; lets async writers detect stale results. */ + getCacheGeneration: () => number; preCacheTokenization: (text: string, data: SubtitleData) => void; consumeCachedSubtitle: (text: string) => SubtitleData | null; hasCachedSubtitle: (text: string) => boolean; @@ -253,6 +255,7 @@ export function createSubtitleProcessingController( tokenizationCache.clear(); cacheGeneration += 1; }, + getCacheGeneration: () => cacheGeneration, preCacheTokenization: (text: string, data: SubtitleData) => { setCachedTokenization(text, data); }, diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 6e24806a..081fbd9a 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -1512,6 +1512,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( '第二走者', diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index 1a8e8de0..4aad3374 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -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)) { diff --git a/src/core/services/tokenizer/hachidori-anki-settings.test.ts b/src/core/services/tokenizer/hachidori-anki-settings.test.ts new file mode 100644 index 00000000..ab93eeae --- /dev/null +++ b/src/core/services/tokenizer/hachidori-anki-settings.test.ts @@ -0,0 +1,191 @@ +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 = { 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 => + 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, 'hachidori']], + ); + 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); +}); + +test('leaves tags edited in Hachidori settings alone', async () => { + const h = await harness({ + templates: [ + { id: 'default', name: 'Default', deck: 'Mining', model: 'Japanese', tags: ['SubMiner'] }, + ], + }); + await h.sync(); + assert.deepEqual(await h.run('options.anki.tags'), ['SubMiner']); +}); + +// 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>; + 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); +}); diff --git a/src/core/services/tokenizer/hachidori-anki-settings.ts b/src/core/services/tokenizer/hachidori-anki-settings.ts new file mode 100644 index 00000000..b4f8d6c4 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-anki-settings.ts @@ -0,0 +1,126 @@ +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; + // SubMiner's tags join Hachidori's own so cards show which backend mined + // them. Only the untouched default is filled; edits in Hachidori settings win. + if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) { + template.tags = [...new Set([...hints.tags, 'hachidori'])]; + } + 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. + } + } + }; +`; diff --git a/src/core/services/tokenizer/hachidori-dictionary-import.test.ts b/src/core/services/tokenizer/hachidori-dictionary-import.test.ts new file mode 100644 index 00000000..7afa8fce --- /dev/null +++ b/src/core/services/tokenizer/hachidori-dictionary-import.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime'; +import { createDeps } from './yomitan-scan-test-harness'; + +// Hachidori deps whose sharing status reports `host`, recording every archive the +// settings automation imports (locally or forwarded over the link). +function hachidoriDeps(host: Record | null) { + const imported: string[] = []; + const runScript = async (script: string): Promise => { + if (script.includes('hd_sharing_status')) { + return { + ok: true, + sharing: { + client: host + ? { linked: true, connected: true, address: 'ws://desktop:8771/link', host } + : { linked: false }, + }, + }; + } + const archiveUrl = /importDictionaryArchiveUrl\(\s*("[^"]+")/.exec(script)?.[1]; + if (archiveUrl) imported.push(await (await fetch(JSON.parse(archiveUrl))).text()); + return true; + }; + const settingsWindow = { + isDestroyed: () => false, + destroy: () => undefined, + webContents: { executeJavaScript: runScript }, + }; + return { + imported, + deps: { + ...createDeps(runScript, { createYomitanExtensionWindow: async () => settingsWindow }), + getYomitanExt: () => ({ + id: 'hachi', + name: 'Hachidori', + version: '1', + path: '', + url: '', + manifest: {}, + }), + }, + }; +} + +async function writeArchive(): Promise { + const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip'); + await writeFile(zipPath, 'PK-test'); + return zipPath; +} + +for (const [name, host] of [ + ['an unlinked Hachidori imports the ZIP itself', null], + [ + 'a linked host that accepts imports gets the ZIP over the link', + { name: 'Helium', dictionaryCount: 8, capabilities: ['linked-import-v1'] }, + ], +] as const) { + test(name, async () => { + const { deps, imported } = hachidoriDeps(host); + assert.equal( + await importYomitanDictionaryFromZip(await writeArchive(), deps, { error: assert.fail }), + true, + ); + assert.deepEqual(imported, ['PK-test']); + }); +} + +test('a linked host without linked imports fails with the update or manual-import fix', async () => { + const { deps, imported } = hachidoriDeps({ name: 'Chrome', dictionaryCount: 8 }); + const zipPath = await writeArchive(); + const errors: string[] = []; + assert.equal( + await importYomitanDictionaryFromZip(zipPath, deps, { + error: (...parts: unknown[]) => errors.push(parts.join(' ')), + }), + false, + ); + assert.deepEqual(imported, []); + assert.match( + errors[0] ?? '', + /Update its Hachidori to 0\.2\.3 or later, or import .*merged\.zip/, + ); +}); diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.test.ts b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts new file mode 100644 index 00000000..d371f765 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-parser-bridge.test.ts @@ -0,0 +1,559 @@ +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: '' }] }], + }, + }; +} + +type UploadReply = Record; +// The parts of vendor/hachidori/extension/linked-import.js the harness drives. +type LinkedImportModule = { + createUploadHost(options: { + store: { + append(token: string, data: string): void; + discard(token: string): void; + }; + importUpload( + token: string, + upload: { fileName: string; replace: boolean }, + ): Promise; + setTimer(): number; + clearTimer(): void; + }): { + begin(message: UploadReply, owner: string): UploadReply; + chunk(message: UploadReply, owner: string): Promise; + commit(message: UploadReply, owner: string): Promise; + abort(message: UploadReply, owner: string): UploadReply; + }; +}; + +async function createHarness(emptyLibrary = false) { + const messages: Array> = []; + 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 uploads: Array<{ fileName: string; replace: boolean; bytes: Buffer }> = []; + const apiModule: unknown = await import( + pathToFileURL(path.join(extensionPath, 'api-host.js')).href + ); + const linkedImportModule = (await import( + pathToFileURL(path.join(extensionPath, 'linked-import.js')).href + )) as LinkedImportModule; + // Hachidori's own upload host, with the engine import replaced by a recorder. + const uploadParts = new Map(); + const uploadHost = linkedImportModule.createUploadHost({ + store: { + append: (token: string, data: string) => { + uploadParts.set(token, [...(uploadParts.get(token) ?? []), Buffer.from(data, 'base64')]); + }, + discard: (token: string) => { + uploadParts.delete(token); + }, + }, + importUpload: async ( + token: string, + { fileName, replace }: { fileName: string; replace: boolean }, + ) => { + uploads.push({ fileName, replace, bytes: Buffer.concat(uploadParts.get(token) ?? []) }); + return { ok: true, report: { success: true } }; + }, + setTimer: () => 0, + clearTimer: () => {}, + }); + const context = vm.createContext({ + __testApiModule: apiModule, + __testLinkedImportModule: linkedImportModule, + setTimeout, + crypto, + URL, + Blob, + Uint8Array, + atob, + chrome: { + storage: { + local: { + set: async (value: Record) => { + proxyUrl = value.subminerAnkiProxyUrl; + }, + get: async () => ({ + options: { anki, scanLength: 40, revision: optionRevision }, + subminerAnkiProxyUrl: proxyUrl, + }), + }, + }, + runtime: { + getManifest: () => ({ version: 'test' }), + sendMessage: async (message: Record) => { + messages.push(structuredClone(message)); + if (message.target === 'hachidori-linked-import') { + // Mirrors background.js answerUploadRequest: commit answers with the import reply. + try { + switch (message.type) { + case 'hd_import_begin': + return { ok: true, ...uploadHost.begin(message, 'local') }; + case 'hd_import_chunk': + return { ok: true, ...(await uploadHost.chunk(message, 'local')) }; + case 'hd_import_abort': + return { ok: true, ...uploadHost.abort(message, 'local') }; + default: + return await uploadHost.commit(message, 'local'); + } + } catch (error) { + return { ok: false, error: (error as Error).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_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('./linked-import.js')", '__testLinkedImportModule') + .replace("await import('./reader-options.js')", 'Promise.resolve()'); + await vm.runInContext(script, context); + const run = async (code: string): Promise => + 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; + }, + uploads: () => uploads, + }; +} + +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: '
  • Frequency:
', + 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 uploads ZIP bytes as a replacement and removes the matching dictionary ID', async () => { + const harness = await createHarness(); + await harness.run( + "__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEstdGVzdA==', 'characters.zip')", + ); + assert.deepEqual(harness.uploads(), [ + { fileName: 'characters.zip', replace: true, bytes: Buffer.from('PK-test') }, + ]); + await harness.run( + `__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`, + ); + 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); +}); diff --git a/src/core/services/tokenizer/hachidori-parser-bridge.ts b/src/core/services/tokenizer/hachidori-parser-bridge.ts new file mode 100644 index 00000000..2772cd17 --- /dev/null +++ b/src/core/services/tokenizer/hachidori-parser-bridge.ts @@ -0,0 +1,275 @@ +// 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'); + const { uploadDictionary } = await import('./linked-import.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 } })); + }; + // Archives go through Hachidori's upload requests: an unlinked install imports + // the ZIP itself and a linked one forwards it to its host. The engine queues + // an import behind any dictionary change already running. + const sendUpload = (type, fields) => chrome.runtime.sendMessage({ + target: 'hachidori-linked-import', type, requestId: crypto.randomUUID(), ...fields, + }); + async function importArchive(blob, fileName) { + await waitForEngineReady(); + const reply = await uploadDictionary({ blob, fileName, replace: true, send: sendUpload }); + if (reply?.report?.success !== true) { + throw new Error(reply?.report?.error || reply?.error || 'Hachidori dictionary import failed'); + } + } + 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'); + }; + })(); +`; diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index 46876d0c..9f3fdcf1 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -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, createBackendDeps, @@ -29,6 +30,102 @@ const TARGET_ANKI_SERVER = 'http://127.0.0.1:8766'; const CUSTOM_ANKI_SERVER = 'http://192.168.1.5:8765'; const CHARACTER_DICTIONARY = 'SubMiner Character Dictionary (AniList 130298)'; +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); + }); +} + interface AnkiSyncProfile { profileCurrent: number; profiles: Array<{ options: { anki: Record } }>; diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index f1bd3d5f..534c282d 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1,7 +1,19 @@ import type { BrowserWindow, Extension, Session } from 'electron'; +import type { AnkiConnectConfig } from '../../../types'; +import { buildHachidoriAnkiHints } from './hachidori-anki-settings'; +import { + buildHachidoriSharingScript, + HACHIDORI_LINKED_IMPORT_CAPABILITY, + 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( } try { + const extension = deps.getYomitanExt(); + if (extension && isHachidoriExtension(extension)) { + await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true); + } await settingsWindow.webContents.executeJavaScript( ` (async () => { @@ -827,6 +872,9 @@ async function serveDictionaryZipOnce( } return await callback(`http://127.0.0.1:${address.port}${requestPath}`); } finally { + // The extension's fetch keeps its socket open; close() alone would wait out + // Node's keep-alive timeout before the import reports completion. + server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } } @@ -892,7 +940,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 +970,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 +1127,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 +1170,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 +1254,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 +1398,7 @@ export async function syncYomitanDefaultAnkiServer( options?: { forceOverride?: boolean; deck?: string; + ankiConfig?: AnkiConnectConfig; }, ): Promise { const normalizedTargetServer = serverUrl.trim(); @@ -1359,7 +1418,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 +1438,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 +1471,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 +1513,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 +1644,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 +1666,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( action: string, params: unknown, @@ -1711,6 +1810,31 @@ 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); + // A linked host takes the ZIP over the link like a local import, below, + // but only once it advertises linked imports (Hachidori 0.2.3+). + if ( + host.kind === 'connected' && + !host.capabilities.includes(HACHIDORI_LINKED_IMPORT_CAPABILITY) + ) { + throw new Error( + `The linked ${host.name} cannot receive dictionary uploads. Update its Hachidori to 0.2.3 or later, or import ${normalizedZipPath} from its Hachidori settings.`, + ); + } + } catch (error) { + logger.error( + 'Hachidori character dictionary import failed:', + error instanceof Error ? error.message : String(error), + ); + return false; + } + } + const supportsUrlImport = await invokeYomitanSettingsAutomation( ` (() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")(); diff --git a/src/core/services/tokenizer/yomitan-scan-runtime-script.ts b/src/core/services/tokenizer/yomitan-scan-runtime-script.ts index f6c9ffeb..018e341a 100644 --- a/src/core/services/tokenizer/yomitan-scan-runtime-script.ts +++ b/src/core/services/tokenizer/yomitan-scan-runtime-script.ts @@ -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; diff --git a/src/core/services/tokenizer/yomitan-scan-test-harness.ts b/src/core/services/tokenizer/yomitan-scan-test-harness.ts index 9c001828..a7e0582a 100644 --- a/src/core/services/tokenizer/yomitan-scan-test-harness.ts +++ b/src/core/services/tokenizer/yomitan-scan-test-harness.ts @@ -30,8 +30,17 @@ export function createDeps( } function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) { + const storage: Record = {}; return { chrome: { + storage: { + local: { + get: async () => ({ ...storage }), + set: async (value: Record) => { + Object.assign(storage, value); + }, + }, + }, runtime: { lastError: null, sendMessage: ( diff --git a/src/core/services/yomitan-extension-copy.ts b/src/core/services/yomitan-extension-copy.ts index aade8025..88be781d 100644 --- a/src/core/services/yomitan-extension-copy.ts +++ b/src/core/services/yomitan-extension-copy.ts @@ -16,6 +16,7 @@ type ExtensionCopyResult = { type ExtensionCopyOptions = { platform?: NodeJS.Platform; + extensionName?: 'yomitan' | 'hachidori'; }; const asyncExtensionCopyInFlight = new Map>(); @@ -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) { diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index 864792de..a05e479a 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -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 diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index 8153c7dc..b316b3c3 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -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(() => { diff --git a/src/main.ts b/src/main.ts index 5553366f..ff8a36e1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,15 @@ import { screen, } from 'electron'; import { applyControllerConfigUpdate } from './main/controller-config-update.js'; +import { + createHachidoriExtensionRuntime, + getHachidoriSession, +} from './core/services/hachidori-extension'; +import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime'; +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'; @@ -387,6 +396,7 @@ import { installLauncher as installCommandLineLauncher, refreshManagedCommandLineLauncher, } from './main/runtime/command-line-launcher'; +import { applyLoginShellPath } from './main/runtime/login-shell-path'; import { createWindowsMpvLaunchDeps, getConfiguredWindowsMpvPathStatus, @@ -902,14 +912,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 | 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, @@ -1007,6 +1037,8 @@ const { } = statsServerRuntime; function requestAppQuit(): void { + destroyYomitanSettingsWindow(hachidoriSettingsWindow); + destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow); destroyYomitanSettingsWindow(appState.yomitanSettingsWindow); appState.yomitanSettingsWindow = null; destroyStatsWindow(); @@ -1463,21 +1495,45 @@ const resolveWindowsMpvShortcutRuntimePaths = () => appDataDir: app.getPath('appData'), desktopDir: app.getPath('desktop'), }); -const createCommandLineLauncherRuntimeOptions = () => ({ - platform: process.platform, - env: process.env, - homeDir: os.homedir(), - localAppData: process.env.LOCALAPPDATA, - userProfile: process.env.USERPROFILE, - cwd: process.cwd(), - resourcesPath: process.resourcesPath, - appExePath: process.execPath, - appVersion: app.getVersion(), - bundledBunPath: app.isPackaged - ? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun') - : undefined, -}); +// Finder/Dock launches inherit launchd's minimal PATH; pick up the user's shell PATH so +// launcher/Bun detection and spawned tools match what their terminal sees. +const loginShellPathReady: Promise = + process.platform === 'darwin' + ? applyLoginShellPath({ env: process.env }) + .then((applied) => { + if (!applied) logger.warn('Login shell PATH was empty; using inherited PATH'); + }) + .catch((error) => { + logger.warn('Failed to read login shell PATH; using inherited PATH', error); + }) + : Promise.resolve(); +const createCommandLineLauncherRuntimeOptions = async () => { + await loginShellPathReady; + return { + platform: process.platform, + env: process.env, + homeDir: os.homedir(), + localAppData: process.env.LOCALAPPDATA, + userProfile: process.env.USERPROFILE, + cwd: process.cwd(), + resourcesPath: process.resourcesPath, + appExePath: process.execPath, + appVersion: app.getVersion(), + bundledBunPath: app.isPackaged + ? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun') + : 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 () => { @@ -1550,10 +1606,10 @@ const firstRunSetupService = createFirstRunSetupService({ shell.writeShortcutLink(shortcutPath, operation, details), }); }, - detectCommandLineLauncher: () => - detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()), + detectCommandLineLauncher: async () => + detectCommandLineLauncher(await createCommandLineLauncherRuntimeOptions()), installBun: async () => { - const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions()); + const snapshot = await installCommandLineBun(await createCommandLineLauncherRuntimeOptions()); return { ok: snapshot.status === 'ready', message: @@ -1564,7 +1620,9 @@ const firstRunSetupService = createFirstRunSetupService({ }; }, installCommandLineLauncher: async () => { - const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions()); + const snapshot = await installCommandLineLauncher( + await createCommandLineLauncherRuntimeOptions(), + ); const ok = snapshot.status === 'ready' || snapshot.status === 'not_on_path'; return { ok, @@ -2080,7 +2138,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, @@ -2146,6 +2221,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ subtitleProcessingController.preCacheTokenization(text, data); }, hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), + getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), onParsedSubtitleCuesChanged: (cues, sourceKey) => { @@ -2331,6 +2407,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(); @@ -2682,8 +2763,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), { error: (message, ...args) => logger.error(message, ...args), info: (message, ...args) => logger.info(message, ...args), + warn: (message, ...args) => logger.warn(message, ...args), }); }, + dictionaryImportReplacesExisting: () => + getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori', deleteYomitanDictionary: async (dictionaryTitle) => { if (yomitanProfilePolicy.isExternalReadOnlyMode()) { yomitanProfilePolicy.logSkippedWrite( @@ -3570,6 +3654,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, @@ -3585,6 +3671,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; @@ -3625,8 +3738,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; } @@ -5300,6 +5413,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( @@ -5312,6 +5460,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), @@ -5339,7 +5520,7 @@ const { getConfiguredShortcuts: () => getConfiguredShortcutsHandler(), registerGlobalShortcutsCore, toggleVisibleOverlay: () => toggleVisibleOverlay(), - openYomitanSettings: () => openYomitanSettings(), + openYomitanSettings: () => openDictionarySettings(), isDev, getMainWindow: () => overlayManager.getMainWindow(), }), @@ -6181,6 +6362,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ }, runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request), ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(), + openHachidoriSettings: () => openHachidoriSettings(), openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => configSettingsRuntime.openWindow(), openSyncUiWindow: () => openSyncUiWindowHandler(), @@ -6317,7 +6499,7 @@ runAndApplyStartupState(); void app.whenReady().then(() => { void takePendingLauncherMigrationPath(async (pendingLauncherPath) => { const acknowledgedPaths = await refreshManagedCommandLineLauncher({ - ...createCommandLineLauncherRuntimeOptions(), + ...(await createCommandLineLauncherRuntimeOptions()), additionalLauncherPaths: pendingLauncherPath ? [pendingLauncherPath] : [], }); return pendingLauncherPath !== undefined && acknowledgedPaths.includes(pendingLauncherPath); @@ -6357,7 +6539,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']), @@ -6445,6 +6628,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(), @@ -6490,12 +6675,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, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index f1ef7491..729ce48a 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext { runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -136,6 +137,7 @@ function createCliCommandDepsFromContext( ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, + openHachidoriSettings: context.openHachidoriSettings, openConfigSettingsWindow: context.openConfigSettingsWindow, openSyncUiWindow: context.openSyncUiWindow, openYoutubeBrowserWindow: context.openYoutubeBrowserWindow, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 9aebcec8..47aa8a2a 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -185,6 +185,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']; openYoutubeBrowserWindow: CliCommandDepsRuntimeOptions['ui']['openYoutubeBrowserWindow']; @@ -372,6 +373,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, openYoutubeBrowserWindow: params.ui.openYoutubeBrowserWindow, diff --git a/src/main/overlay-runtime-test-harness.ts b/src/main/overlay-runtime-test-harness.ts index 62ef889f..531fafdc 100644 --- a/src/main/overlay-runtime-test-harness.ts +++ b/src/main/overlay-runtime-test-harness.ts @@ -56,6 +56,7 @@ export class FakeOverlayWindow { private readonly name: string; private readonly log: string[] | undefined; private loadListeners: Array<() => void> = []; + private stopLoadingListeners: Array<() => void> = []; private readyToShowListeners: Array<() => void> = []; readonly webContents = { @@ -68,8 +69,10 @@ export class FakeOverlayWindow { focus: (): void => { this.webContentsFocused = true; }, - once: (_event: 'did-finish-load', listener: () => void): void => { - this.loadListeners.push(listener); + once: (event: 'did-finish-load' | 'did-stop-loading', listener: () => void): void => { + (event === 'did-stop-loading' ? this.stopLoadingListeners : this.loadListeners).push( + listener, + ); }, }; @@ -136,12 +139,14 @@ export class FakeOverlayWindow { this.readyToShowListeners.push(listener); } - // Commits the modal document and fires the queued did-finish-load listeners. + // Commits the modal document like Electron does: isLoading() stays true inside + // did-finish-load and only settles before did-stop-loading. finishLoad(): void { - this.loading = false; this.url = MODAL_URL; this[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] = true; for (const listener of this.loadListeners.splice(0)) listener(); + this.loading = false; + for (const listener of this.stopLoadingListeners.splice(0)) listener(); } // Marks renderer content ready and fires the queued ready-to-show listeners. diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 60152803..b204c6fa 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -131,6 +131,26 @@ for (const platform of ['darwin', 'win32'] as const) { }); } +test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => { + // ready-to-show can fire before the document loads, and Electron still reports + // isLoading() inside did-finish-load; only did-stop-loading sees the settled state. + const modal = new FakeOverlayWindow({ ...LOADING_BLANK_WINDOW, documentLoaded: false }); + const h = createHarness({ createModal: () => modal }); + + assert.equal( + h.runtime.sendToActiveOverlayWindow( + 'media-timing-review:open', + { reviewId: 'r1' }, + { restoreOnModalClose: 'media-timing-review', preferModalWindow: true }, + ), + true, + ); + modal.emitReadyToShow(); + assert.deepEqual(modal.sent, []); + modal.finishLoad(); + assert.deepEqual(modal.sent, [['media-timing-review:open', { reviewId: 'r1' }]]); +}); + test('primeModalWindow leaves Linux modal creation lazy', () => { const h = createHarness({ createModal: () => new FakeOverlayWindow(), platform: 'linux' }); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 8244f521..fbe008f9 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -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)); }; diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index ab10ccf2..b419ef3f 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -179,6 +179,45 @@ const konosuba = { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba' const towerOfGod = { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God' }; const eminence = { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence' }; +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(); writeState(userDataPath, { diff --git a/src/main/runtime/character-dictionary-auto-sync.ts b/src/main/runtime/character-dictionary-auto-sync.ts index 82e27ec6..4bb7ad0c 100644 --- a/src/main/runtime/character-dictionary-auto-sync.ts +++ b/src/main/runtime/character-dictionary-auto-sync.ts @@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps { waitForYomitanMutationReady?: () => Promise; getYomitanDictionaryInfo: () => Promise; importYomitanDictionary: (zipPath: string) => Promise; + dictionaryImportReplacesExisting?: () => boolean; deleteYomitanDictionary: (dictionaryTitle: string) => Promise; 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), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 4ef7215d..1de16bf2 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -108,6 +109,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, + openHachidoriSettings: deps.openHachidoriSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index d0d93d6a..0d52a0ce 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -72,6 +72,7 @@ function buildContextDeps(overrides: Partial = {}) { runUpdateCommand: noopAsync, runEnsureLinuxRuntimePluginAssetsCommand: noopAsync, runYoutubePlaybackFlow: noopAsync, + openHachidoriSettings: noop, openYomitanSettings: noop, openConfigSettingsWindow: noop, openSyncUiWindow: noop, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 31e47196..bd01174e 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -145,6 +146,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(), openYoutubeBrowserWindow: () => deps.openYoutubeBrowserWindow(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index c23be099..1ef1412f 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -52,6 +52,7 @@ function createDeps() { runUpdateCommand: async () => {}, runEnsureLinuxRuntimePluginAssetsCommand: async () => {}, runYoutubePlaybackFlow: async () => {}, + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 188292a3..6c12524b 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = { runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow']; ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer']; openYomitanSettings: () => void; + openHachidoriSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; openYoutubeBrowserWindow: () => void; @@ -135,6 +136,7 @@ export function createCliCommandContext( runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer, openYomitanSettings: deps.openYomitanSettings, + openHachidoriSettings: deps.openHachidoriSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, openYoutubeBrowserWindow: deps.openYoutubeBrowserWindow, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index a300ac3f..460880bd 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -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): Promise | void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-')); @@ -30,6 +35,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { toggleVisibleOverlay: false, togglePrimarySubtitleBar: false, yomitan: false, + hachidori: false, settings: false, syncWindow: false, youtubeBrowser: false, @@ -763,3 +769,151 @@ 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, + capabilities: [], + }; + 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'); + // The link is still connecting at startup; that must not undo a finished setup. + host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Connecting' }; + snapshot = await service.ensureSetupStateInitialized(); + assert.equal(snapshot.canFinish, false); + assert.equal(snapshot.dictionaryCount, 0); + assert.equal(snapshot.state.status, 'completed'); + assert.equal(service.isSetupCompleted(), true); + host = { + kind: 'connected', + address: 'ws://127.0.0.1:8771/link', + name: 'Docker', + dictionaryCount: 0, + capabilities: [], + }; + assert.equal((await service.getSetupStatus()).canFinish, false); + // A reachable host with no dictionaries does reopen setup. + assert.equal((await service.ensureSetupStateInitialized()).state.status, 'incomplete'); + assert.notEqual((await service.markSetupCompleted()).state.status, 'completed'); + host = { kind: 'local' }; + assert.equal((await service.getSetupStatus()).dictionaryCount, 7); + assert.equal((await service.getSetupStatus()).canFinish, true); + }); +}); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 8a9f1241..09bb8dfc 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -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; + getDictionaryBackend?: () => DictionaryBackend; + getHachidoriHostStatus?: () => Promise; 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; + getDictionaryBackend?: () => DictionaryBackend; + getHachidoriHostStatus?: () => Promise; isExternalYomitanConfigured?: () => boolean; detectPluginInstalled: () => boolean | Promise; 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 { configReady, dictionaryCount, externalYomitanConfigured } = + 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, hachidoriHost } = await resolveYomitanSetupStatus({ configFilePaths, getYomitanDictionaryCount: deps.getYomitanDictionaryCount, + getDictionaryBackend, + getHachidoriHostStatus: deps.getHachidoriHostStatus, isExternalYomitanConfigured: deps.isExternalYomitanConfigured, }); const canFinish = isYomitanSetupSatisfied({ @@ -365,7 +461,11 @@ export function createFirstRunSetupService(deps: { dictionaryCount, externalYomitanConfigured, }); - if (isSetupCompleted(state) && canFinish) { + // A linked host is usually still connecting at startup, so an unreachable host + // says nothing about its dictionaries; only a reachable empty host reopens setup. + const hostUnreachable = + hachidoriHost?.kind === 'disconnected' || hachidoriHost?.kind === 'unavailable'; + if (isSetupCompleted(state) && (canFinish || (configReady && hostUnreachable))) { completed = true; return refreshWithState(state); } diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index 28950411..86ab8f74 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -727,3 +727,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, /
/); + 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' }, + ); +}); diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index bd91fac9..1db2cf9b 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -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' - | 'remove-legacy-plugin' - | 'configure-windows-mpv-shortcuts' - | 'install-bun' - | 'install-command-line-launcher' - | 'open-yomitan-settings' - | 'open-config-settings' - | 'refresh' - | 'finish'; +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' + | '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; @@ -251,9 +259,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string { ` : ''; - const yomitanMeta = model.externalYomitanConfigured - ? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.' - : `${model.dictionaryCount} installed`; + 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' + ? ` +
+ Dictionary source +

Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.

+
+ Use an external dictionary host +

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.

+

Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.

+
    +
  • Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.
  • +
  • Desktop app: keep the app sharing your dictionaries and any required relay running.
  • +
  • Docker: keep the dictionary container running. You can close its browser management page.
  • +
+

If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.

+
+ + +
Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.
+
+ + ${linked ? `` : ''} +
+
+ ${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `

${escapeHtml(host.message)}

` : ''} +
+
` + : ''; + 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 ? 'External' : model.dictionaryCount >= 1 @@ -270,8 +316,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 ` @@ -340,6 +386,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; @@ -468,17 +529,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
- Yomitan dictionaries + ${dictionaryName} dictionaries
${escapeHtml(yomitanMeta)}
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
+ ${hostCard} ${mpvExecutablePathCard} ${windowsShortcutCard} ${renderCommandLineLauncherSection(model.commandLineLauncher)} ${legacyPluginCard}
- + @@ -497,6 +559,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' && @@ -509,6 +573,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, diff --git a/src/main/runtime/login-shell-path.test.ts b/src/main/runtime/login-shell-path.test.ts new file mode 100644 index 00000000..aba64e1d --- /dev/null +++ b/src/main/runtime/login-shell-path.test.ts @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { applyLoginShellPath, mergePathValues, readLoginShellPath } from './login-shell-path'; + +const wrap = (value: string) => + `motd noise\n__SUBMINER_LOGIN_PATH__${value}\n__SUBMINER_LOGIN_PATH__`; + +test('readLoginShellPath extracts PATH between markers, ignoring rc-file output', async () => { + let invoked: { shell: string; args: string[] } | null = null; + const value = await readLoginShellPath({ + env: { SHELL: '/bin/zsh' }, + runShell: async (shell, args) => { + invoked = { shell, args }; + return wrap('/Users/me/.local/bin:/usr/bin'); + }, + }); + assert.equal(value, '/Users/me/.local/bin:/usr/bin'); + assert.equal(invoked!.shell, '/bin/zsh'); + assert.equal(invoked!.args[0], '-ilc'); +}); + +test('readLoginShellPath returns null when markers are missing', async () => { + const value = await readLoginShellPath({ env: {}, shell: '/bin/sh', runShell: async () => '' }); + assert.equal(value, null); +}); + +test('readLoginShellPath rejects relative explicit shells before execution', async () => { + for (const shell of ['zsh', './zsh', '']) { + await assert.rejects( + readLoginShellPath({ + env: { SHELL: '/bin/zsh' }, + shell, + runShell: async () => assert.fail('Invalid shell must not execute'), + }), + /Login shell must be an absolute path/, + ); + } +}); + +test('readLoginShellPath falls back through absolute default shells', async () => { + const originalUserInfo = os.userInfo; + try { + for (const shell of ['/bin/bash', 'bash', '', null]) { + Object.defineProperty(os, 'userInfo', { + value: () => ({ shell, username: 'test', uid: 1000, gid: 1000, homedir: '/Users/test' }), + }); + await readLoginShellPath({ + env: { SHELL: './zsh' }, + runShell: async (selectedShell) => { + assert.equal(selectedShell, shell === '/bin/bash' ? '/bin/bash' : '/bin/zsh'); + return wrap('/usr/bin'); + }, + }); + } + Object.defineProperty(os, 'userInfo', { + value: () => { + throw new Error('User lookup failed'); + }, + }); + await readLoginShellPath({ + env: { SHELL: 'zsh' }, + runShell: async (shell) => { + assert.equal(shell, '/bin/zsh'); + return wrap('/usr/bin'); + }, + }); + } finally { + os.userInfo = originalUserInfo; + } +}); + +test('mergePathValues puts login entries first and keeps process-only entries', () => { + assert.equal( + mergePathValues('/Users/me/.local/bin:/usr/bin:/bin', '/usr/bin:/bin:/usr/sbin:/sbin'), + '/Users/me/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin', + ); +}); + +test('applyLoginShellPath updates env.PATH so launcher detection sees shell dirs', async () => { + const env: NodeJS.ProcessEnv = { PATH: '/usr/bin:/bin' }; + const applied = await applyLoginShellPath({ + env, + shell: '/bin/zsh', + runShell: async () => wrap('/Users/me/.local/bin:/usr/bin'), + }); + assert.equal(applied, true); + assert.equal(env.PATH, '/Users/me/.local/bin:/usr/bin:/bin'); +}); diff --git a/src/main/runtime/login-shell-path.ts b/src/main/runtime/login-shell-path.ts new file mode 100644 index 00000000..76ade52f --- /dev/null +++ b/src/main/runtime/login-shell-path.ts @@ -0,0 +1,72 @@ +import { execFile } from 'node:child_process'; +import os from 'node:os'; +import { isAbsolute } from 'node:path'; + +const MARKER = '__SUBMINER_LOGIN_PATH__'; +const DEFAULT_TIMEOUT_MS = 5000; + +export type RunShell = (shell: string, args: string[], timeoutMs: number) => Promise; + +type LoginShellPathOptions = { + env: NodeJS.ProcessEnv; + shell?: string; + runShell?: RunShell; + timeoutMs?: number; +}; + +const runShellDefault: RunShell = (shell, args, timeoutMs) => + new Promise((resolve, reject) => { + const child = execFile( + shell, + args, + { + timeout: timeoutMs, + encoding: 'utf8', + env: { ...process.env, DISABLE_AUTO_UPDATE: 'true' }, + }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + child.stdin?.end(); + }); + +function defaultShell(env: NodeJS.ProcessEnv): string { + if (env.SHELL && isAbsolute(env.SHELL)) return env.SHELL; + try { + const shell = os.userInfo().shell; + return shell && isAbsolute(shell) ? shell : '/bin/zsh'; + } catch { + return '/bin/zsh'; + } +} + +/** + * Reads PATH as the user's interactive login shell sees it. macOS GUI apps inherit + * launchd's minimal PATH, so dirs added in ~/.zshrc and friends are otherwise invisible. + */ +export async function readLoginShellPath(options: LoginShellPathOptions): Promise { + const shell = options.shell ?? defaultShell(options.env); + if (!isAbsolute(shell)) throw new Error('Login shell must be an absolute path'); + const run = options.runShell ?? runShellDefault; + // printenv keeps this shell-agnostic (fish exposes $PATH as a list). + const command = `printf '%s' '${MARKER}'; /usr/bin/printenv PATH; printf '%s' '${MARKER}'`; + const stdout = await run(shell, ['-ilc', command], options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const value = stdout.split(MARKER)[1]?.trim(); + return value || null; +} + +/** Puts login-shell entries first (terminal precedence), keeping any process-only entries after. */ +export function mergePathValues(loginPath: string, currentPath: string | undefined): string { + const merged: string[] = []; + for (const entry of [...loginPath.split(':'), ...(currentPath ?? '').split(':')]) { + if (entry && !merged.includes(entry)) merged.push(entry); + } + return merged.join(':'); +} + +/** Merges the login-shell PATH into `options.env.PATH`. Resolves false when it couldn't be read. */ +export async function applyLoginShellPath(options: LoginShellPathOptions): Promise { + const loginPath = await readLoginShellPath(options); + if (!loginPath) return false; + options.env.PATH = mergePathValues(loginPath, options.env.PATH); + return true; +} diff --git a/src/main/runtime/managed-launcher.test.ts b/src/main/runtime/managed-launcher.test.ts index fa1af02d..f11d9a67 100644 --- a/src/main/runtime/managed-launcher.test.ts +++ b/src/main/runtime/managed-launcher.test.ts @@ -372,12 +372,19 @@ test('Windows stages a new runtime version while the prior Bun executable is run await exited; } } - cleanupOldWindowsManagedRuntimes({ - platform: 'win32', - localAppData: root, - appVersion: '2.0.0', - }); - assert.equal(fs.existsSync(path.dirname(first.bunPath)), false); + // 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', + }); + if (!fs.existsSync(firstDirectory)) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.equal(fs.existsSync(firstDirectory), false); assert.ok(fs.existsSync(expectedSecond.bunPath)); }); diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts index 3aac18b6..8099756f 100644 --- a/src/main/runtime/media-timing-review.test.ts +++ b/src/main/runtime/media-timing-review.test.ts @@ -95,9 +95,11 @@ async function startActiveMediaTimingReview( decisionTimeoutMs?: number; generateWaveform?: () => Promise; play?: () => Promise; + paused?: boolean; } = {}, ) { const previewCalls: Array<[number, number]> = []; + const commands: Array> = []; let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void; const openedPayload = new Promise((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 () => { @@ -646,6 +649,105 @@ 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 honors an overlay pause that arrives while setup reads the pause state', async () => { + const commands: Array> = []; + const pauseReads: Array<(paused: boolean) => void> = []; + let runtime: ReturnType; + runtime = createMediaTimingReviewRuntime({ + getMpvClient: () => ({ + connected: true, + currentVideoPath: '/video/show.mkv', + requestProperty: (name) => + name === 'pause' + ? new Promise((resolve) => pauseReads.push(resolve)) + : Promise.resolve(name === 'duration' ? 100 : null), + send: ({ command }) => commands.push(command), + }), + getCurrentMediaPath: () => '/video/show.mkv', + getMpvExecutablePath: () => 'mpv', + generateWaveform: async () => [], + createPreviewSession: () => ({ + start: async () => undefined, + play: async () => undefined, + stop: async () => undefined, + onPlaybackEnded: () => undefined, + dispose: () => undefined, + }), + openModal: async (payload) => { + queueMicrotask(() => { + runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } }); + }); + return true; + }, + showStatus: () => undefined, + }); + const request = { + kind: 'sentence' as const, + text: '字幕', + startTime: 10, + endTime: 12, + audioPadding: 0, + maxMediaDuration: 30, + }; + + const first = runtime.requestReview(request); + runtime.cancelPlaybackResume(); + // mpv answered the read before the overlay's pause reached it. + pauseReads[0]!(false); + await first; + assert.deepEqual(commands, [['set_property', 'pause', 'yes']]); + + commands.length = 0; + const second = runtime.requestReview(request); + pauseReads[1]!(false); + await second; + assert.deepEqual(commands, [ + ['set_property', 'pause', 'yes'], + ['set_property', 'pause', 'no'], + ]); +}); + test('media timing review watchdog falls back when the renderer stops responding', async () => { const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 }); diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts index e79fa1f7..0e6b1715 100644 --- a/src/main/runtime/media-timing-review.ts +++ b/src/main/runtime/media-timing-review.ts @@ -280,6 +280,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep let active: ActiveReview | null = null; let currentRequest: ReviewRequestLifecycle | null = null; let pendingPauseRestore: ReviewMpvClient | null = null; + let resumeDeferred = false; + /** Set when the overlay pauses during the current request, even before its setup reads the pause state. */ + let restoreCancelled = false; function restorePendingPlayback(): void { const mpvClient = pendingPauseRestore; @@ -289,6 +292,37 @@ 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 { + if (!currentRequest) return; + resumeDeferred = false; + restoreCancelled = true; + if (active) active.restorePlayback = false; + } + function ensureWindow( review: ActiveReview, range: RemoteMediaWindowRange, @@ -413,7 +447,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] = setup.values; const pauseState = booleanProperty(pauseRaw); - pendingPauseRestore = pauseState === false ? mpvClient : null; + // The pause read can predate an overlay pause that arrived during setup. + pendingPauseRestore = pauseState === false && !restoreCancelled ? mpvClient : null; mpvClient.send({ command: ['set_property', 'pause', 'yes'] }); if (lifecycle.isCancelled()) { restorePendingPlayback(); @@ -531,6 +566,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } const lifecycle = createReviewRequestLifecycle(); currentRequest = lifecycle; + restoreCancelled = false; try { return await runReview(request, lifecycle); } catch { @@ -541,6 +577,7 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep } finally { if (currentRequest === lifecycle) { currentRequest = null; + resumeDeferredPlayback(); } lifecycle.markSettled(); } @@ -739,7 +776,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'] }); } } @@ -760,6 +798,8 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep getFrame, stopPreview, resolveReview, + deferPlaybackResume, + cancelPlaybackResume, dispose, }; } diff --git a/src/main/runtime/startup-mode-flags.test.ts b/src/main/runtime/startup-mode-flags.test.ts index 397b6771..0602affa 100644 --- a/src/main/runtime/startup-mode-flags.test.ts +++ b/src/main/runtime/startup-mode-flags.test.ts @@ -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); + }); +} diff --git a/src/main/runtime/startup-mode-flags.ts b/src/main/runtime/startup-mode-flags.ts index 2f295ea9..a5029472 100644 --- a/src/main/runtime/startup-mode-flags.ts +++ b/src/main/runtime/startup-mode-flags.ts @@ -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 || diff --git a/src/main/runtime/stats-server-runtime.test.ts b/src/main/runtime/stats-server-runtime.test.ts index bf7810df..de4ff8e3 100644 --- a/src/main/runtime/stats-server-runtime.test.ts +++ b/src/main/runtime/stats-server-runtime.test.ts @@ -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'; @@ -30,6 +32,7 @@ function createDeferred() { function createRuntimeHarness( startServer: NonNullable, backgroundState: BackgroundStatsServerState | null = null, + overrides: Partial = {}, ) { const appStateValues: Array = []; const tracker = new ImmersionTrackerService({ dbPath: ':memory:' }); @@ -65,10 +68,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>[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('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => { const calls: string[] = []; const runtime = createStatsServerRuntime({ diff --git a/src/main/runtime/stats-server-runtime.ts b/src/main/runtime/stats-server-runtime.ts index 23378b78..df994caa 100644 --- a/src/main/runtime/stats-server-runtime.ts +++ b/src/main/runtime/stats-server-runtime.ts @@ -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) { diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts index 1754fca6..640657cd 100644 --- a/src/main/runtime/subtitle-prefetch-init.test.ts +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues' assert.equal(warnings.length, 1); assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/); }); + +function createTrackingController(loadContent: () => string) { + let currentService: SubtitlePrefetchService | null = null; + const events: string[] = []; + const cueUpdates: Array = []; + let serviceCount = 0; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => loadContent(), + parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }], + createSubtitlePrefetchService: () => { + const id = ++serviceCount; + return { + start: () => events.push(`start:${id}`), + stop: () => events.push(`stop:${id}`), + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }; + }, + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + logInfo: () => {}, + logWarn: () => {}, + onParsedSubtitleCuesChanged: (cues, source) => { + cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null); + }, + }); + + return { controller, events, cueUpdates }; +} + +test('re-initializing an unchanged source keeps the running service and republishes cues', async () => { + const { controller, events, cueUpdates } = createTrackingController(() => 'content'); + + await controller.initSubtitlePrefetch('track-1.srt', 0); + await controller.initSubtitlePrefetch('track-1.srt', 3); + + assert.deepEqual(events, ['start:1']); + assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']); +}); + +test('re-initializing a source whose content changed restarts the service', async () => { + let content = 'first'; + const { controller, events } = createTrackingController(() => content); + + await controller.initSubtitlePrefetch('track-1.srt', 0); + content = 'second'; + await controller.initSubtitlePrefetch('track-1.srt', 0); + + assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']); +}); + +test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => { + let prefetchedTexts: string[] = []; + let publishedTexts: string[] = []; + let currentService: SubtitlePrefetchService | null = null; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => 'content', + parseSubtitleCues: (): SubtitleCue[] => [ + { startTime: 0, endTime: 4, text: 'first' }, + { startTime: 2, endTime: 6, text: 'second' }, + ], + createSubtitlePrefetchService: ({ cues }) => { + prefetchedTexts = cues.map((cue) => cue.text); + return { + start: () => {}, + stop: () => {}, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }; + }, + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + logInfo: () => {}, + logWarn: () => {}, + onParsedSubtitleCuesChanged: (cues) => { + publishedTexts = cues?.map((cue) => cue.text) ?? []; + }, + }); + + await controller.initSubtitlePrefetch('track.srt', 0); + + assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']); + assert.deepEqual(publishedTexts, ['first', 'second']); +}); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index aa605a0d..fc14e662 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -4,6 +4,7 @@ import type { } from '../../core/services/subtitle-prefetch'; import type { SubtitleData } from '../../types'; import type { SubtitleCue } from '../../types'; +import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps'; export interface SubtitlePrefetchInitControllerDeps { getCurrentService: () => SubtitlePrefetchService | null; @@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps { tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; hasCachedTokenization?: (text: string) => boolean; + getCacheGeneration?: () => number; logInfo: (message: string) => void; logWarn: (message: string) => void; onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void; @@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController( deps: SubtitlePrefetchInitControllerDeps, ): SubtitlePrefetchInitController { let initRevision = 0; + // What the running service was built from. Startup fires several inits for one source + // (Jellyfin preload seed, sid change, each track-list update); restarting on each would + // discard the in-flight tokenization and re-queue the priority window behind it. + let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null; + + const stopCurrentService = (): void => { + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + activeSource = null; + }; const cancelPendingInit = (): void => { initRevision += 1; - deps.getCurrentService()?.stop(); - deps.setCurrentService(null); + stopCurrentService(); deps.onParsedSubtitleCuesChanged?.(null, null); }; @@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController( sourceKey = sourcePath, ): Promise => { const revision = ++initRevision; - deps.getCurrentService()?.stop(); - deps.setCurrentService(null); + // The same source may still be current once loaded; keep its service running until then. + if (activeSource?.key !== sourceKey) { + stopCurrentService(); + } try { const content = await deps.loadSubtitleSourceText(sourcePath); if (revision !== initRevision) { return; } + if ( + activeSource?.key === sourceKey && + activeSource.content === content && + deps.getCurrentService() + ) { + // A track change clears the published cues synchronously, so republish them. + deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey); + return; + } + stopCurrentService(); const cues = deps.parseSubtitleCues(content, sourcePath); if (revision !== initRevision || cues.length === 0) { @@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController( return; } + // Overlap cues only feed the cache; the published cue list stays as authored. + const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort( + (a, b) => a.startTime - b.startTime, + ); const nextService = deps.createSubtitlePrefetchService({ - cues, + cues: prefetchCues, tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false, + getCacheGeneration: deps.getCacheGeneration, }); if (revision !== initRevision) { @@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController( } deps.setCurrentService(nextService); + activeSource = { key: sourceKey, content, cues }; deps.onParsedSubtitleCuesChanged?.(cues, sourceKey); nextService.start(currentTimePos); deps.logInfo( @@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController( ); } catch (error) { if (revision === initRevision) { + stopCurrentService(); deps.onParsedSubtitleCuesChanged?.(null, null); deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`); } diff --git a/src/main/runtime/subtitle-prefetch-overlaps.test.ts b/src/main/runtime/subtitle-prefetch-overlaps.test.ts new file mode 100644 index 00000000..adc86525 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-overlaps.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SubtitleCue } from '../../types'; +import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps'; + +test('overlapping cues yield the combined line the live path resolves', () => { + const cues: SubtitleCue[] = [ + { startTime: 0, endTime: 4, text: 'このまま頑張ったって―' }, + { startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' }, + ]; + + assert.deepEqual(buildOverlapPrefetchCues(cues), [ + { startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' }, + ]); +}); + +test('back-to-back cues yield no overlap lines', () => { + const cues: SubtitleCue[] = [ + { startTime: 0, endTime: 2, text: 'first' }, + { startTime: 2, endTime: 4, text: 'second' }, + ]; + + assert.deepEqual(buildOverlapPrefetchCues(cues), []); +}); + +test('each overlap span joins only the cues active across it, in cue-list order', () => { + const cues: SubtitleCue[] = [ + { startTime: 3, endTime: 8, text: 'C' }, + { startTime: 0, endTime: 6, text: 'A' }, + { startTime: 2, endTime: 4, text: 'B' }, + ]; + + assert.deepEqual(buildOverlapPrefetchCues(cues), [ + { startTime: 2, endTime: 3, text: 'A\n\nB' }, + { startTime: 3, endTime: 4, text: 'C\n\nA\n\nB' }, + { startTime: 4, endTime: 6, text: 'C\n\nA' }, + ]); +}); diff --git a/src/main/runtime/subtitle-prefetch-overlaps.ts b/src/main/runtime/subtitle-prefetch-overlaps.ts new file mode 100644 index 00000000..0b5bc03b --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-overlaps.ts @@ -0,0 +1,61 @@ +import type { SubtitleCue } from '../../types'; +import { resolvePrimarySubtitleText } from './primary-subtitle-text'; + +/** + * While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live + * path tokenizes and caches that combined line as its own entry. Prefetching single cues + * never warms it, so overlapping lines (usually the long ones) always miss the cache. + * + * Returns one synthetic cue per overlap span carrying the text the live path resolves + * for it, so the prefetcher can warm those lines in timeline order with the rest. + */ +export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] { + const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort( + (a, b) => a - b, + ); + const indices = cues.map((_, index) => index); + const byStart = [...indices].sort((a, b) => cues[a]!.startTime - cues[b]!.startTime); + const byEnd = [...indices].sort((a, b) => cues[a]!.endTime - cues[b]!.endTime); + const singleTexts = new Set(cues.map((cue) => cue.text)); + const seen = new Set(); + const overlapCues: SubtitleCue[] = []; + + // Sweep the boundaries once, keeping the cues active over [startTime, endTime). Every + // cue edge is a boundary, so a cue is active for a whole interval or not at all. + const active = new Set(); + let nextStart = 0; + let nextEnd = 0; + for (let i = 0; i + 1 < boundaries.length; i += 1) { + const startTime = boundaries[i]!; + const endTime = boundaries[i + 1]!; + for (; nextEnd < byEnd.length && cues[byEnd[nextEnd]!]!.endTime <= startTime; nextEnd += 1) { + active.delete(byEnd[nextEnd]!); + } + for ( + ; + nextStart < byStart.length && cues[byStart[nextStart]!]!.startTime <= startTime; + nextStart += 1 + ) { + const index = byStart[nextStart]!; + // Empty or inverted cues are never on screen. + if (cues[index]!.endTime > startTime) active.add(index); + } + if (active.size < 2) continue; + + // mpv joins simultaneous cues in cue-list order. + const liveText = [...active] + .sort((a, b) => a - b) + .map((index) => cues[index]!.text) + .join('\n'); + const text = resolvePrimarySubtitleText({ + liveText, + currentTimeSec: (startTime + endTime) / 2, + cues, + }); + if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue; + seen.add(text); + overlapCues.push({ startTime, endTime, text }); + } + + return overlapCues; +} diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 4101b08f..07c0820a 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -26,6 +26,8 @@ function buildHandlers(overrides: Partial = {}) { showFirstRunSetup: () => true, openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => false, + getDictionaryBackend: () => 'yomitan', + openHachidoriSettings: () => {}, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, openSyncUiWindow: () => {}, diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index dce6aeea..c17072fd 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function createResolveTrayIconPathHandler(deps: { resolveTrayIconPathRuntime: (options: { platform: string; @@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -68,6 +72,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { showFirstRunSetup: () => boolean; openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; + getDictionaryBackend: () => DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; @@ -109,6 +115,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { deps.openFirstRunSetupWindow(true); }, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(), + dictionaryBackend: deps.getDictionaryBackend(), + openHachidoriSettings: deps.openHachidoriSettings, openYomitanSettings: () => { deps.openYomitanSettings(); }, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 4b587a38..d18c0555 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function createBuildResolveTrayIconPathMainDepsHandler(deps: { resolveTrayIconPathRuntime: (options: { platform: string; @@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -58,6 +62,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: () => boolean; openFirstRunSetupWindow: (force?: boolean) => void; showWindowsMpvLauncherSetup: () => boolean; + getDictionaryBackend: () => DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; openSyncUiWindow: () => void; @@ -84,6 +90,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showFirstRunSetup: deps.showFirstRunSetup, openFirstRunSetupWindow: deps.openFirstRunSetupWindow, showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, + getDictionaryBackend: deps.getDictionaryBackend, + openHachidoriSettings: deps.openHachidoriSettings, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, openSyncUiWindow: deps.openSyncUiWindow, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 059f0d88..6e23aa77 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -38,6 +38,8 @@ function baseHandlers(overrides: Partial = {}): TrayMenuHandle showFirstRunSetup: false, openWindowsMpvLauncherSetup: () => undefined, showWindowsMpvLauncherSetup: false, + dictionaryBackend: 'yomitan', + openHachidoriSettings: () => undefined, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, openSyncUi: () => undefined, @@ -74,6 +76,27 @@ for (const entry of optionalEntries) { }); } +for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) { + test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => { + const calls: string[] = []; + const template = buildTrayMenuTemplateRuntime( + baseHandlers({ + dictionaryBackend, + openHachidoriSettings: () => calls.push('hachidori'), + openYomitanSettings: () => calls.push('yomitan'), + }), + ); + const labels = template.flatMap((entry) => (entry.label ? [entry.label] : [])); + const [shown, hidden] = + dictionaryBackend === 'hachidori' + ? ['Open Hachidori Settings', 'Open Yomitan Settings'] + : ['Open Yomitan Settings', 'Open Hachidori Settings']; + assert.equal(labels.includes(hidden), false); + template.find((entry) => entry.label === shown)?.click?.(); + assert.deepEqual(calls, [dictionaryBackend]); + }); +} + test('tray menu ends with a separator and Quit', () => { const template = buildTrayMenuTemplateRuntime(baseHandlers()); assert.deepEqual( diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 50b4ad09..d4e13836 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -1,3 +1,5 @@ +import type { DictionaryBackend } from '../../types/config'; + export function resolveTrayIconPathRuntime(deps: { platform: string; resourcesPath: string; @@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = { showFirstRunSetup: boolean; openWindowsMpvLauncherSetup: () => void; showWindowsMpvLauncherSetup: boolean; + dictionaryBackend: DictionaryBackend; + openHachidoriSettings: () => void; openYomitanSettings: () => void; openConfigSettings: () => void; openSyncUi: () => void; @@ -103,8 +107,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): ] : []), { - label: 'Open Yomitan Settings', - click: handlers.openYomitanSettings, + label: + handlers.dictionaryBackend === 'hachidori' + ? 'Open Hachidori Settings' + : 'Open Yomitan Settings', + click: + handlers.dictionaryBackend === 'hachidori' + ? handlers.openHachidoriSettings + : handlers.openYomitanSettings, }, { label: 'Open SubMiner Settings', diff --git a/src/main/runtime/yomitan-anki-server-sync.test.ts b/src/main/runtime/yomitan-anki-server-sync.test.ts index c3884431..a94cc180 100644 --- a/src/main/runtime/yomitan-anki-server-sync.test.ts +++ b/src/main/runtime/yomitan-anki-server-sync.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { AnkiConnectConfig, ResolvedConfig } from '../../types'; -import { createYomitanAnkiServerSyncRuntime } from './yomitan-anki-server-sync'; +import { + buildYomitanAnkiSettingsKey, + createYomitanAnkiServerSyncRuntime, +} from './yomitan-anki-server-sync'; +import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings'; type SyncCall = { url: string; forceOverride: boolean; deck: string }; @@ -64,3 +68,15 @@ test('retries the same settings after a failed sync', async () => { await harness.sync(); assert.equal(harness.calls.length, 2); }); + +test('settings sync key changes when fields or tags change', () => { + const key = (word: string, tags: string[]) => + buildYomitanAnkiSettingsKey({ + targetUrl: 'http://127.0.0.1:8766', + targetDeck: 'Mining', + forceOverride: true, + hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }), + }); + assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner'])); + assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese'])); +}); diff --git a/src/main/runtime/yomitan-anki-server-sync.ts b/src/main/runtime/yomitan-anki-server-sync.ts index 63b7d38d..0616a04a 100644 --- a/src/main/runtime/yomitan-anki-server-sync.ts +++ b/src/main/runtime/yomitan-anki-server-sync.ts @@ -1,5 +1,6 @@ import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services'; import type { ResolvedConfig } from '../../types'; +import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings'; import { getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime, shouldForceOverrideYomitanAnkiServer, @@ -18,8 +19,9 @@ export function buildYomitanAnkiSettingsKey(options: { targetUrl: string; targetDeck: string; forceOverride: boolean; + hachidoriHints?: ReturnType; }): string { - return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`; + return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`; } export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): { @@ -47,6 +49,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu targetUrl, targetDeck, forceOverride, + hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig), }); if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) { return; @@ -66,6 +69,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu { forceOverride, deck: targetDeck, + ankiConfig: ankiConnectConfig, }, ); diff --git a/src/preload-clipboard.test.ts b/src/preload-clipboard.test.ts index d0bd4224..f8dacf36 100644 --- a/src/preload-clipboard.test.ts +++ b/src/preload-clipboard.test.ts @@ -18,6 +18,7 @@ test('sidebar clipboard bridge writes exact text without renderer focus and reje let exposed: unknown; runInNewContext(output.text, { process: { argv: [] }, + window: { addEventListener: () => {} }, require: (name: string) => { assert.equal(name, 'electron'); return { diff --git a/src/preload.ts b/src/preload.ts index 22abbaa8..45a19000 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -19,6 +19,44 @@ import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron'; import { resolveOverlayLayerFromArgv } from './preload-args'; import { createIpcListenerFactories } from './preload-ipc-listeners'; +import { + DICTIONARY_EXTERNAL_LINK_CHANNEL, + parseDictionaryExternalUrl, +} from './shared/dictionary-external-link'; + +window.addEventListener('hachidori-open-external', (event) => { + if (!(event instanceof CustomEvent)) return; + const detail: unknown = event.detail; + if ( + !detail || + typeof detail !== 'object' || + !('requestId' in detail) || + typeof detail.requestId !== 'string' || + !('url' in detail) + ) + return; + const requestId = detail.requestId; + void Promise.resolve() + .then(() => + ipcRenderer.invoke(DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl(detail.url)), + ) + .then(() => { + window.dispatchEvent( + new CustomEvent('hachidori-open-external-result', { detail: { requestId, ok: true } }), + ); + }) + .catch((error: unknown) => { + window.dispatchEvent( + new CustomEvent('hachidori-open-external-result', { + detail: { + requestId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + }), + ); + }); +}); import type { SubtitleData, SubtitlePosition, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 0dff8e0f..3c55d917 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; +import { HACHIDORI_HOST_SELECTOR } from '../yomitan-popup.js'; import type { CompiledSessionBinding } from '../../types'; import type { MpvInputBindingsSnapshot } from '../../types/session-bindings'; import { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; @@ -152,6 +153,10 @@ function installKeyboardTestGlobals() { let selectionAddCount = 0; let popupVisible = false; + let hachidoriPaneOpen = false; + const hachidoriHost = { + shadowRoot: { querySelectorAll: () => [{ hidden: false }] }, + }; const popupIframe = { tagName: 'IFRAME', @@ -285,7 +290,10 @@ function installKeyboardTestGlobals() { listeners.push(listener); documentListeners.set(type, listeners); }, - querySelectorAll: () => { + querySelectorAll: (selector: string) => { + if (selector === HACHIDORI_HOST_SELECTOR) { + return hachidoriPaneOpen ? [hachidoriHost] : []; + } if (popupVisible) { return [popupIframe]; } @@ -455,6 +463,9 @@ function installKeyboardTestGlobals() { setPopupVisible: (value: boolean) => { popupVisible = value; }, + setHachidoriPaneOpen: (value: boolean) => { + hachidoriPaneOpen = value; + }, setStatsToggleKey: (value: string) => { statsToggleKey = value; }, @@ -475,6 +486,18 @@ function installKeyboardTestGlobals() { target.closest = (selector: string) => (selector.includes('.modal') ? target : null); return target; }, + // Events inside Hachidori's shadow root reach the document retargeted to its host. + createDictionaryPopupHostTarget: () => { + const target = new TestElement(); + target.closest = (selector: string) => + selector + .split(',') + .map((part) => part.trim()) + .includes(HACHIDORI_HOST_SELECTOR) + ? target + : null; + return target; + }, createTextInputTarget: () => { const target = new TestElement(); target.tagName = 'INPUT'; @@ -714,6 +737,23 @@ test('right-clicking interactive overlay controls does not raise playback window } }); +test('right-clicking inside a dictionary popup host does not raise playback window or toggle pause', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + const popupHost = testGlobals.createDictionaryPopupHostTarget(); + + try { + await handlers.setupMpvInputForwarding(); + + testGlobals.dispatchDocumentMouseDown({ button: 2, target: popupHost }); + await wait(0); + + assert.deepEqual(testGlobals.interactionActivations, []); + assert.deepEqual(testGlobals.mpvCommands, []); + } finally { + testGlobals.restore(); + } +}); + test('mpv input forwarding retries a transient keyboard config IPC failure', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); let calls = 0; @@ -1177,7 +1217,13 @@ test('imported mpv wheel bindings forward from the overlay but not over modals', testGlobals.dispatchWheel({ deltaY: 120 }); testGlobals.dispatchWheel({ deltaY: -120, target: testGlobals.createInteractiveTarget() }); + // A Hachidori popup scrolls itself; its events arrive retargeted to its host. + const popupScrolled = !testGlobals.dispatchWheel({ + deltaY: -120, + target: testGlobals.createDictionaryPopupHostTarget(), + }); + assert.equal(popupScrolled, true); assert.deepEqual(testGlobals.mpvCommands, [['keypress', 'WHEEL_DOWN', 1]]); } finally { testGlobals.restore(); @@ -1222,6 +1268,86 @@ test('imported mpv mouse buttons forward only after SubMiner mouse handling', as } }); +test('Hachidori attention without an open popup pane still forwards imported mpv input', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + testGlobals.setGetMpvInputBindings(async () => ({ + keys: ['0', 'MBTN_LEFT_DBL'], + blockedKeys: [], + })); + await handlers.setupMpvInputForwarding(); + await wait(0); + // Hachidori claims attention for any press that may start a selection. + ctx.state.hachidoriReaderSeen = true; + ctx.state.yomitanPopupVisible = true; + + testGlobals.dispatchKeydown({ key: '0', code: 'Digit0' }); + testGlobals.dispatchMousedown({ button: 0 }); + testGlobals.dispatchMousedown({ button: 0, type: 'mouseup' }); + + assert.deepEqual(testGlobals.commandEvents, []); + assert.deepEqual(testGlobals.mpvCommands, [ + ['keydown', '0'], + ['keydown', 'MBTN_LEFT'], + ['keyup', 'MBTN_LEFT'], + ]); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: lookup toggle opens a lookup during Hachidori attention without a pane', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + ctx.state.keyboardSelectedWordIndex = 1; + handlers.syncKeyboardTokenSelection(); + ctx.state.hachidoriReaderSeen = true; + ctx.state.yomitanPopupVisible = true; + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true); + assert.deepEqual( + testGlobals.commandEvents.filter((event) => event.type === 'setVisible'), + [], + ); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + +test('keyboard mode: lookup toggle closes an open Hachidori pane after attention is released', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.handleKeyboardModeToggleRequested(); + // A selection drag leaves the pane open while the Yomitan-shaped visible marker clears. + ctx.state.hachidoriReaderSeen = true; + ctx.state.yomitanPopupVisible = false; + testGlobals.setHachidoriPaneOpen(true); + + handlers.handleLookupWindowToggleRequested(); + await wait(0); + + assert.deepEqual( + testGlobals.commandEvents.filter( + (event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource', + ), + [{ type: 'setVisible', visible: false }, { type: 'clearActiveTextSource' }], + ); + } finally { + ctx.state.keyboardDrivenModeEnabled = false; + testGlobals.restore(); + } +}); + test('configured subtitle-jump keybinding preserves pause when pause state is unknown', async () => { const { handlers, testGlobals } = createKeyboardHandlerHarness(); diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index a79547be..262399b4 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -4,9 +4,12 @@ import { createMpvInputForwarding } from './mpv-input-forwarding'; import { MPV_MOUSE_BUTTON_BY_BUTTON, wheelEventToMpvWheel } from '../../shared/mpv-input-bindings'; import { dispatchConfiguredMpvCommand } from '../utils/mpv-command-dispatch'; import { - YOMITAN_POPUP_HIDDEN_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, + HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_COMMAND_EVENT, + YOMITAN_POPUP_HOST_SELECTOR, + isHachidoriPopupOpen, + isLookupPopupOpen, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; @@ -80,6 +83,8 @@ export function createKeyboardHandlers( if (target.closest('.modal')) return true; if (ctx.dom.subtitleContainer.contains(target)) return true; if (isYomitanPopupIframe(target)) return true; + // Hachidori's popup lives in a shadow root, so its events arrive retargeted to the host. + if (target.closest(`${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`)) return true; if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]')) return true; return false; @@ -106,11 +111,13 @@ export function createKeyboardHandlers( } // Overlay UI that handles its own mouse input (menus, sidebar, notifications, controls). + // Hachidori's popup lives in the overlay document, unlike Yomitan's iframe, so its + // wheel events reach these handlers retargeted to its host. function isOverlayControlTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return Boolean( target.closest( - '.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea', + `.modal, .notification-history, .overlay-notification-stack, button, a, input, select, textarea, ${YOMITAN_POPUP_HOST_SELECTOR}, ${HACHIDORI_HOST_SELECTOR}`, ), ); } @@ -122,8 +129,7 @@ export function createKeyboardHandlers( !ctx.state.playlistBrowserModalOpen && !ctx.state.youtubePickerModalOpen && !ctx.state.subtitleSidebarModalOpen && - !ctx.state.yomitanPopupVisible && - !isYomitanPopupVisible(document) && + !isLookupPopupOpen(ctx.state, document) && !isInteractiveTarget(target) ); } @@ -515,8 +521,7 @@ export function createKeyboardHandlers( pendingSelectionAnchorAfterSubtitleSeek = null; resetSelectionToStartOnNextSubtitleSync = false; const shouldRefreshLookup = - pendingLookupRefreshAfterSubtitleSeek && - (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)); + pendingLookupRefreshAfterSubtitleSeek && isLookupPopupOpen(ctx.state, document); pendingLookupRefreshAfterSubtitleSeek = false; if (shouldRefreshLookup) { queueMicrotask(() => { @@ -708,7 +713,7 @@ export function createKeyboardHandlers( if (lookupScanFallbackTimer !== null) clearTimeout(lookupScanFallbackTimer); lookupScanFallbackTimer = setTimeout(() => { lookupScanFallbackTimer = null; - if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + if (isLookupPopupOpen(ctx.state, document)) { return; } // Dispatch directly on the selected token span; when overlay pointer-events are disabled, @@ -738,15 +743,22 @@ export function createKeyboardHandlers( } function handleLookupWindowToggleRequested(): void { - if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + if (isLookupPopupOpen(ctx.state, document)) { closeLookupWindow(); return; } triggerLookupForSelectedWord(); } + // Closes any popup state: an open Hachidori pane (its attention may already be + // released) and attention without a pane, so callers like the Kiku modal + // opener leave nothing behind. function closeLookupWindow(): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if ( + !ctx.state.yomitanPopupVisible && + !isYomitanPopupVisible(document) && + !isHachidoriPopupOpen(document) + ) { return false; } @@ -770,7 +782,7 @@ export function createKeyboardHandlers( return false; } - const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); + const popupVisible = isLookupPopupOpen(ctx.state, document); const result = moveKeyboardSelection(delta); if (result === 'no-words') { seekAdjacentSubtitleAndQueueSelection(delta, popupVisible); @@ -791,7 +803,7 @@ export function createKeyboardHandlers( code: string, repeat: boolean = true, ): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return false; } dispatchYomitanPopupKeydown(key, code, [], repeat); @@ -799,7 +811,7 @@ export function createKeyboardHandlers( } function mineSelectedFromController(): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return false; } dispatchYomitanPopupMineSelected(); @@ -807,7 +819,7 @@ export function createKeyboardHandlers( } function cyclePopupAudioSourceForController(direction: -1 | 1): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return false; } dispatchYomitanPopupCycleAudioSource(direction); @@ -815,7 +827,7 @@ export function createKeyboardHandlers( } function playCurrentAudioForController(): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return false; } dispatchYomitanPopupPlayCurrentAudio(); @@ -823,7 +835,7 @@ export function createKeyboardHandlers( } function scrollPopupByController(deltaX: number, deltaY: number): boolean { - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return false; } dispatchYomitanPopupScrollBy(deltaX, deltaY); @@ -850,7 +862,7 @@ export function createKeyboardHandlers( if (!ctx.state.keyboardDrivenModeEnabled) { return; } - if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) { + if (!isLookupPopupOpen(ctx.state, document)) { return; } restoreOverlayKeyboardFocus(); @@ -895,7 +907,7 @@ export function createKeyboardHandlers( } const key = e.code; - const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document); + const popupVisible = isLookupPopupOpen(ctx.state, document); if (key === 'ArrowLeft' || key === 'KeyH') { const result = moveKeyboardSelection(-1); if (result === 'start-boundary' || result === 'no-words') { @@ -1086,7 +1098,7 @@ export function createKeyboardHandlers( subtree: true, }); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { + registerDictionaryPopupVisibilityListener('hidden', () => { clearNativeSubtitleSelection(); if (!ctx.state.keyboardDrivenModeEnabled) { syncKeyboardTokenSelection(); @@ -1094,7 +1106,7 @@ export function createKeyboardHandlers( } restoreOverlayKeyboardFocus(); }); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { + registerDictionaryPopupVisibilityListener('shown', () => { if (!ctx.state.keyboardDrivenModeEnabled) { return; } @@ -1251,7 +1263,7 @@ export function createKeyboardHandlers( return; } - if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { + if (isLookupPopupOpen(ctx.state, document)) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); return; diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index e9b7657d..c7c711bd 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -5,6 +5,11 @@ import { SUBTITLE_DEFAULT_CONFIG } from '../../config/definitions/defaults-subti import { createRendererState } from '../state'; import { createMouseHandlers } from './mouse.js'; import { + HACHIDORI_HOST_SELECTOR, + HACHIDORI_POPUP_HIDDEN_EVENT, + HACHIDORI_POPUP_SELECTOR, + HACHIDORI_POPUP_SHOWN_EVENT, + YOMITAN_LOOKUP_EVENT, YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_HOST_SELECTOR, YOMITAN_POPUP_MOUSE_ENTER_EVENT, @@ -50,6 +55,8 @@ const MOCKED_GLOBALS = ['window', 'document', 'MutationObserver', 'Node'] as con * Installs window/document/MutationObserver/Node stubs (restored via `t.after`) and builds * mouse handlers with no-op defaults. `hoverPause`, `popupPause`, `hovered` and * `popupHostVisible` are mutable mid-test; `fire` dispatches to the registered listeners. + * With `hachidori`, the popup host is a Hachidori shadow host: `popupHostVisible` is its + * attention flag, `popupPaneOpen` its popup pane, and `popupHostAttached` its presence. */ function createMouseHarness( t: TestContext, @@ -61,6 +68,7 @@ function createMouseHarness( getPlaybackPaused?: () => Promise; popupHostVisible?: boolean; hovered?: HoverTarget; + hachidori?: boolean; } = {}, ) { const overlayClassList = createClassList(); @@ -102,11 +110,23 @@ function createMouseHarness( }; const ignoreCalls: IgnoreCall[] = []; const mpvCommands: Array<(string | number)[]> = []; - const popupHost = { - tagName: 'DIV', - getAttribute: (name: string) => - name === 'data-subminer-yomitan-popup-visible' ? 'true' : null, + const popupPane = { + get hidden() { + return !harness.popupPaneOpen; + }, }; + const popupHost = { + tagName: options.hachidori ? 'HACHIDORI-HOST' : 'DIV', + getAttribute: (name: string) => + name === 'data-subminer-yomitan-popup-visible' ? String(harness.popupHostVisible) : null, + shadowRoot: { + querySelectorAll: (selector: string) => + selector === HACHIDORI_POPUP_SELECTOR ? [popupPane] : [], + }, + }; + const hostSelectors = options.hachidori + ? [HACHIDORI_HOST_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR] + : [YOMITAN_POPUP_HOST_SELECTOR]; const harness = { ctx, @@ -118,6 +138,8 @@ function createMouseHarness( popupPause: options.popupPause ?? false, hovered: options.hovered ?? null, popupHostVisible: options.popupHostVisible ?? false, + popupHostAttached: options.hachidori ?? false, + popupPaneOpen: false, handlers: null as unknown as ReturnType, fire: (target: keyof typeof listeners, type: string, event: unknown = {}) => { for (const listener of listeners[target].get(type) ?? []) listener(event); @@ -157,12 +179,15 @@ function createMouseHarness( body: { classList: bodyClassList }, addEventListener: addListener('document'), querySelector: () => null, - querySelectorAll: (selector: string) => - harness.popupHostVisible && - (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || - selector === YOMITAN_POPUP_HOST_SELECTOR) + querySelectorAll: (selector: string) => { + if (selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) { + return harness.popupHostVisible ? [popupHost] : []; + } + return hostSelectors.includes(selector) && + (harness.popupHostVisible || harness.popupHostAttached) ? [popupHost] - : [], + : []; + }, elementFromPoint: () => harness.hovered === 'primary' ? ctx.dom.subtitleContainer @@ -375,6 +400,72 @@ for (const c of [ }); } +// Hachidori publishes one attention signal for an open popup and for a press on +// subtitle text that may become a selection, so its popup pane is the source of +// truth for what is on screen. + +test('Hachidori press on subtitle text does not pause before a popup opens', async (t) => { + const h = createMouseHarness(t, { popupPause: true, hachidori: true }); + h.handlers.setupYomitanObserver(); + + h.popupHostVisible = true; + h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT); + await waitForNextTick(); + h.popupHostVisible = false; + h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT); + await waitForNextTick(); + + assert.deepEqual(h.mpvCommands, []); +}); + +test('Hachidori press before any lookup does not pause while its host is unattached', async (t) => { + const h = createMouseHarness(t, { popupPause: true, hachidori: true }); + h.handlers.setupYomitanObserver(); + + // Hachidori attaches its host on the first lookup, so a press anywhere on + // the overlay before that claims attention with nothing in the DOM. + h.popupHostAttached = false; + h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT); + await waitForNextTick(); + h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT); + await waitForNextTick(); + + assert.deepEqual(h.mpvCommands, []); +}); + +test('Hachidori selection lookup pauses once its popup opens and resumes on close', async (t) => { + const h = createMouseHarness(t, { popupPause: true, hachidori: true }); + h.handlers.setupYomitanObserver(); + + h.popupHostVisible = true; + h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT); + await waitForNextTick(); + assert.deepEqual(h.mpvCommands, []); + + // The drag's attention carries over to its lookup, so no second shown event arrives. + h.popupPaneOpen = true; + h.fire('window', YOMITAN_LOOKUP_EVENT); + await waitForNextTick(); + assert.deepEqual(h.mpvCommands, [PAUSE]); + + h.popupPaneOpen = false; + h.popupHostVisible = false; + h.fire('window', HACHIDORI_POPUP_HIDDEN_EVENT); + assert.deepEqual(h.mpvCommands, [PAUSE, RESUME]); +}); + +test('Hachidori hover popup pauses when shown', async (t) => { + const h = createMouseHarness(t, { popupPause: true, hachidori: true }); + h.handlers.setupYomitanObserver(); + + h.popupPaneOpen = true; + h.popupHostVisible = true; + h.fire('window', HACHIDORI_POPUP_SHOWN_EVENT); + await waitForNextTick(); + + assert.deepEqual(h.mpvCommands, [PAUSE]); +}); + test('popup shown reclaims overlay focus on macOS and captures click-away', (t) => { const h = createMouseHarness(t, { platform: 'macos' }); h.handlers.setupYomitanObserver(); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 36959d0e..394a1fee 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -1,11 +1,12 @@ import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { - YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_MOUSE_ENTER_EVENT, YOMITAN_POPUP_MOUSE_LEAVE_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, + registerYomitanLookupListener, PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS, + isHachidoriPopupOpen, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; @@ -257,8 +258,20 @@ export function createMouseHandlers( options.sendMpvCommand(['set_property', 'pause', 'no']); } + // Hachidori also claims attention for a left press anywhere on the overlay + // that may become a selection, so once its events identify the reader only + // an open popup pane should pause playback. + function canPauseForYomitanPopup(): boolean { + return ( + yomitanPopupVisible && + options.getYomitanPopupAutoPauseEnabled() && + (!ctx.state.hachidoriReaderSeen || + (typeof document !== 'undefined' && isHachidoriPopupOpen(document))) + ); + } + async function maybePauseForYomitanPopup(): Promise { - if (!yomitanPopupVisible || !options.getYomitanPopupAutoPauseEnabled()) { + if (!canPauseForYomitanPopup()) { return; } @@ -278,11 +291,7 @@ export function createMouseHandlers( return; } - if ( - requestId !== popupPauseRequestId || - !yomitanPopupVisible || - !options.getYomitanPopupAutoPauseEnabled() - ) { + if (requestId !== popupPauseRequestId || !canPauseForYomitanPopup()) { return; } if (paused !== false) return; @@ -470,7 +479,8 @@ export function createMouseHandlers( function setupYomitanObserver(): void { reconcilePopupInteraction({ allowPause: true }); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { + registerDictionaryPopupVisibilityListener('shown', (reader) => { + if (reader === 'hachidori') ctx.state.hachidoriReaderSeen = true; reconcilePopupInteraction({ assumeVisible: true, allowPause: true, @@ -478,10 +488,16 @@ export function createMouseHandlers( }); }); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { + registerDictionaryPopupVisibilityListener('hidden', () => { disablePopupInteractionIfIdle(); }); + // A Hachidori selection opens its popup while the drag's attention claim is + // still held, so no second shown event arrives; its lookup marks the open. + registerYomitanLookupListener(window, () => { + reconcilePopupInteraction({ allowPause: true }); + }); + window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => { ctx.state.isOverYomitanPopup = true; reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true }); diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 8c043f49..a3efdf35 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -403,7 +403,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] { { shortcut: 'Y then S', action: 'Start overlay' }, { shortcut: 'Y then Shift + S', action: 'Stop overlay' }, { shortcut: 'Y then T', action: 'Toggle visible overlay' }, - { shortcut: 'Y then O', action: 'Open Yomitan settings' }, + { shortcut: 'Y then O', action: 'Open dictionary settings' }, { shortcut: 'Y then R', action: 'Restart overlay' }, { shortcut: 'Y then C', action: 'Check overlay status' }, { shortcut: 'Y then H/K', action: 'Open session help' }, @@ -412,7 +412,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] { }, { title: 'Global shortcuts', - rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }], + rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }], }, ]; } diff --git a/src/renderer/modals/subtitle-sidebar.ts b/src/renderer/modals/subtitle-sidebar.ts index a1ae391a..df3ad235 100644 --- a/src/renderer/modals/subtitle-sidebar.ts +++ b/src/renderer/modals/subtitle-sidebar.ts @@ -12,8 +12,7 @@ import { hasSubtitleSidebarSelection, } from './subtitle-sidebar-selection.js'; import { - YOMITAN_POPUP_HIDDEN_EVENT, - YOMITAN_POPUP_SHOWN_EVENT, + registerDictionaryPopupVisibilityListener, isYomitanPopupVisible, } from '../yomitan-popup.js'; @@ -850,12 +849,18 @@ export function createSubtitleSidebarModal( syncEmbeddedSidebarLayout(); }; window.addEventListener('resize', resizeHandler); - window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); - window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); + const disposeShown = registerDictionaryPopupVisibilityListener( + 'shown', + handleYomitanPopupShown, + ); + const disposeHidden = registerDictionaryPopupVisibilityListener( + 'hidden', + handleYomitanPopupHidden, + ); disposeDomEvents = () => { window.removeEventListener('resize', resizeHandler); - window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown); - window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden); + disposeShown(); + disposeHidden(); disposeDomEvents = null; }; } diff --git a/src/renderer/modals/youtube-track-picker.test.ts b/src/renderer/modals/youtube-track-picker.test.ts index 5eb68468..052c5814 100644 --- a/src/renderer/modals/youtube-track-picker.test.ts +++ b/src/renderer/modals/youtube-track-picker.test.ts @@ -224,7 +224,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => { assert.equal(state.youtubePickerModalOpen, false); assert.deepEqual(notifications, ['youtube-track-picker']); - assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]); + assert.deepEqual(frontendCommands, []); assert.equal(overlay.classList.contains('interactive'), false); assert.equal(overlayFocused, true); assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); diff --git a/src/renderer/modals/youtube-track-picker.ts b/src/renderer/modals/youtube-track-picker.ts index efade404..ae06c6fc 100644 --- a/src/renderer/modals/youtube-track-picker.ts +++ b/src/renderer/modals/youtube-track-picker.ts @@ -1,6 +1,5 @@ import type { YoutubePickerOpenPayload } from '../../types'; import type { ModalStateReader, RendererContext } from '../context'; -import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js'; function createOption(value: string, label: string): HTMLOptionElement { const option = document.createElement('option'); @@ -191,13 +190,6 @@ export function createYoutubeTrackPickerModal( ctx.dom.youtubePickerModal.classList.add('hidden'); ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true'); window.electronAPI.notifyOverlayModalClosed('youtube-track-picker'); - window.dispatchEvent( - new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, { - detail: { - type: 'refreshOptions', - }, - }), - ); if (!options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index eec6e991..fb19100b 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -61,7 +61,7 @@ import { createOverlayNotificationHistoryPanel } from './overlay-notification-hi import { createRendererState } from './state.js'; import { createModalRegistry, type ModalDescriptor } from './modal-registry.js'; import { createSubtitleRenderer } from './subtitle-render.js'; -import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js'; +import { isLookupPopupOpen, registerYomitanLookupListener } from './yomitan-popup.js'; import { createRendererRecoveryController, registerRendererGlobalErrorHandlers, @@ -457,7 +457,7 @@ function startControllerPolling(): void { profiles: {}, }, getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled, - getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document), + getLookupWindowOpen: () => isLookupPopupOpen(ctx.state, document), getInteractionBlocked: () => isControllerInputBlocked(), toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(), toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(), diff --git a/src/renderer/state.ts b/src/renderer/state.ts index d7feb5e0..216de219 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -161,6 +161,10 @@ export type RendererState = { keyboardSelectionVisible: boolean; keyboardSelectedWordIndex: number | null; yomitanPopupVisible: boolean; + // Set once Hachidori's attention events identify it as the reader. Its attention + // also covers presses that may start a selection, so yomitanPopupVisible alone + // does not mean a popup is open. + hachidoriReaderSeen: boolean; isOverYomitanPopup: boolean; primarySubtitleMode: PrimarySubMode; }; @@ -296,6 +300,7 @@ export function createRendererState(): RendererState { keyboardSelectionVisible: false, keyboardSelectedWordIndex: null, yomitanPopupVisible: false, + hachidoriReaderSeen: false, isOverYomitanPopup: false, primarySubtitleMode: 'visible', }; diff --git a/src/renderer/yomitan-popup.test.ts b/src/renderer/yomitan-popup.test.ts index 516659cd..bdd8a854 100644 --- a/src/renderer/yomitan-popup.test.ts +++ b/src/renderer/yomitan-popup.test.ts @@ -7,11 +7,36 @@ import { hasYomitanPopupIframe, isYomitanPopupIframe, isYomitanPopupVisible, + registerDictionaryPopupVisibilityListener, } from './yomitan-popup.js'; type FakeStyle = Pick; type FakeFrame = Element & { fakeStyle: FakeStyle }; +test('native popup attention events from either backend have the same lifecycle', () => { + for (const backend of ['yomitan', 'hachidori']) { + const target = new EventTarget(); + const calls: string[] = []; + const disposeShown = registerDictionaryPopupVisibilityListener( + 'shown', + () => calls.push('shown'), + target, + ); + const disposeHidden = registerDictionaryPopupVisibilityListener( + 'hidden', + () => calls.push('hidden'), + target, + ); + target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`)); + target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`)); + disposeShown(); + disposeHidden(); + target.dispatchEvent(new CustomEvent(`${backend}-popup-shown`)); + target.dispatchEvent(new CustomEvent(`${backend}-popup-hidden`)); + assert.deepEqual(calls, ['shown', 'hidden']); + } +}); + const VISIBLE_STYLE: FakeStyle = { visibility: 'visible', display: 'block', opacity: '1' }; function frame(options: { width?: number; height?: number; style?: Partial } = {}) { diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index e3301e5e..44fce021 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -10,6 +10,41 @@ export const YOMITAN_POPUP_MOUSE_LEAVE_EVENT = 'yomitan-popup-mouse-leave'; export const YOMITAN_POPUP_COMMAND_EVENT = 'subminer-yomitan-popup-command'; export const YOMITAN_LOOKUP_EVENT = 'subminer-yomitan-lookup'; export const PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS = 'primary-sub-visible-on-yomitan-popup'; +// Hachidori's shown/hidden pair means "the reader needs mouse events", which +// also covers a left press on subtitle text that may start a selection. Its +// popup panes live in the host's open shadow root. +export const HACHIDORI_POPUP_SHOWN_EVENT = 'hachidori-popup-shown'; +export const HACHIDORI_POPUP_HIDDEN_EVENT = 'hachidori-popup-hidden'; +export const HACHIDORI_HOST_SELECTOR = 'hachidori-host'; +export const HACHIDORI_POPUP_SELECTOR = '.gsm-hoshidicts-popup'; + +export type DictionaryReader = 'yomitan' | 'hachidori'; + +// Only the active backend injects a reader. Consume its native attention events. +export function registerDictionaryPopupVisibilityListener( + state: 'shown' | 'hidden', + listener: (reader: DictionaryReader) => void, + target: EventTarget = window, +): () => void { + const events: Array<[string, DictionaryReader]> = + state === 'shown' + ? [ + [YOMITAN_POPUP_SHOWN_EVENT, 'yomitan'], + [HACHIDORI_POPUP_SHOWN_EVENT, 'hachidori'], + ] + : [ + [YOMITAN_POPUP_HIDDEN_EVENT, 'yomitan'], + [HACHIDORI_POPUP_HIDDEN_EVENT, 'hachidori'], + ]; + const wrapped = events.map(([event, reader]) => { + const handler = (): void => listener(reader); + target.addEventListener(event, handler); + return [event, handler] as const; + }); + return () => { + for (const [event, handler] of wrapped) target.removeEventListener(event, handler); + }; +} export function registerYomitanLookupListener( target: EventTarget = window, @@ -73,6 +108,33 @@ function queryPopupElements( return []; } +/** + * Whether a Hachidori popup pane is on screen. Auto-pause reads this because + * the host's visible marker only tracks Hachidori's attention signal. Hachidori + * attaches its host on the first lookup, so no host means no popup. + */ +export function isHachidoriPopupOpen(root: ParentNode | null | undefined = document): boolean { + const hosts = queryPopupElements(root, HACHIDORI_HOST_SELECTOR); + return hosts.some((host) => + queryPopupElements(host.shadowRoot, HACHIDORI_POPUP_SELECTOR).some( + (pane) => !pane.hidden, + ), + ); +} + +/** + * Whether a lookup popup is open, for input that should go to it instead of mpv. + * Once Hachidori is the reader only an open pane counts, because its attention + * (the shown event and the host's visible marker) also covers a plain press. + */ +export function isLookupPopupOpen( + state: { yomitanPopupVisible: boolean; hachidoriReaderSeen: boolean }, + root: ParentNode | null | undefined = document, +): boolean { + if (state.hachidoriReaderSeen) return isHachidoriPopupOpen(root); + return state.yomitanPopupVisible || isYomitanPopupVisible(root); +} + export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean { const visiblePopupHosts = queryPopupElements( root, diff --git a/src/settings/settings-anki-controls.ts b/src/settings/settings-anki-controls.ts index 31adebe6..19072d65 100644 --- a/src/settings/settings-anki-controls.ts +++ b/src/settings/settings-anki-controls.ts @@ -230,12 +230,12 @@ async function loadYomitanAnkiDeckName(): Promise { state.yomitanAnkiDeckNameError = null; } else { state.yomitanAnkiDeckName = ''; - state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.'; + state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read the dictionary Anki deck.'; } } catch (error) { state.yomitanAnkiDeckName = ''; state.yomitanAnkiDeckNameError = - error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.'; + error instanceof Error ? error.message : 'Failed to read the dictionary Anki deck.'; } finally { state.yomitanAnkiDeckNameLoading = false; requestRender(); diff --git a/src/shared/anki-source.ts b/src/shared/anki-source.ts new file mode 100644 index 00000000..b41b3f55 --- /dev/null +++ b/src/shared/anki-source.ts @@ -0,0 +1,2 @@ +// Stats cards already own their sentence and media, including while polling Anki directly. +export const STATS_MINING_TAG = 'SubMiner::Stats'; diff --git a/src/shared/dictionary-external-link.test.ts b/src/shared/dictionary-external-link.test.ts new file mode 100644 index 00000000..840e4015 --- /dev/null +++ b/src/shared/dictionary-external-link.test.ts @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDictionaryExternalUrl } from './dictionary-external-link'; + +test('dictionary links accept web URLs and reject privileged schemes and credentials', () => { + assert.equal( + parseDictionaryExternalUrl('https://example.com/word?q=猫'), + 'https://example.com/word?q=%E7%8C%AB', + ); + for (const value of [ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'https://user:pass@example.com', + null, + {}, + ]) { + assert.throws(() => parseDictionaryExternalUrl(value)); + } +}); diff --git a/src/shared/dictionary-external-link.ts b/src/shared/dictionary-external-link.ts new file mode 100644 index 00000000..db52ed27 --- /dev/null +++ b/src/shared/dictionary-external-link.ts @@ -0,0 +1,10 @@ +export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external'; + +export function parseDictionaryExternalUrl(value: unknown): string { + if (typeof value !== 'string') throw new Error('Expected a link URL'); + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new Error('Dictionary links must use HTTP or HTTPS without credentials'); + } + return url.href; +} diff --git a/src/shared/hachidori-sharing.test.ts b/src/shared/hachidori-sharing.test.ts new file mode 100644 index 00000000..c353c6ff --- /dev/null +++ b/src/shared/hachidori-sharing.test.ts @@ -0,0 +1,161 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing'; +import { runInNewContext } from 'node:vm'; + +test('host status rejects malformed replies and distinguishes local, connected and offline', () => { + assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), { + kind: 'local', + }); + assert.throws( + () => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }), + /Connection refused/, + ); + assert.throws( + () => + parseHachidoriHostStatus({ + ok: true, + sharing: { + client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } }, + }, + }), + /dictionary count/, + ); + assert.equal( + parseHachidoriHostStatus({ + ok: true, + sharing: { client: { linked: true, address: 'host', connected: false } }, + }).kind, + 'disconnected', + ); +}); + +test('sharing script safely links an address and checks live engine inventory', async () => { + const address = `ws://host:8771/link?quote="`; + const requests: Array> = []; + const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address }); + const value: unknown = await runInNewContext(script, { + crypto, + setTimeout, + clearTimeout, + chrome: { + runtime: { + sendMessage: async (message: Record) => { + requests.push(message); + if (message.type === 'hd_status') return { ok: true, ready: true, loading: false }; + if (message.type === 'hd_state_read') + return { ok: true, state: { dictionaries: [{}, {}] } }; + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address, + host: { name: 'Host', dictionaryCount: 99, capabilities: ['linked-import-v1', 7] }, + }, + }, + }; + }, + }, + }, + }); + assert.equal(requests[0]?.address, address); + assert.deepEqual(parseHachidoriHostStatus(value), { + kind: 'connected', + address, + name: 'Host', + dictionaryCount: 2, + capabilities: ['linked-import-v1'], + }); +}); + +test('an unresponsive host preserves its address so setup can still unlink', async () => { + const value: unknown = await runInNewContext( + buildHachidoriSharingScript({ type: 'hd_sharing_status' }), + { + crypto, + setTimeout, + clearTimeout, + chrome: { + runtime: { + sendMessage: async (message: { type: string }) => { + if (message.type === 'hd_status') throw new Error('Host stopped responding'); + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address: 'ws://host:8771/link', + host: { dictionaryCount: 1 }, + }, + }, + }; + }, + }, + }, + }, + ); + assert.deepEqual(parseHachidoriHostStatus(value), { + kind: 'disconnected', + address: 'ws://host:8771/link', + message: 'Host stopped responding', + }); +}); + +for (const stalledCall of ['initial', 'refresh']) { + test(`sharing ${stalledCall} requests time out and clear their timers`, async () => { + const timers = new Map void>(); + let nextTimer = 0; + let sharingCalls = 0; + let stalled = false; + const result: Promise = runInNewContext( + buildHachidoriSharingScript({ type: 'hd_sharing_status' }), + { + crypto, + setTimeout: (callback: () => void, delay: number) => { + assert.equal(delay, 5000); + timers.set(++nextTimer, callback); + return nextTimer; + }, + clearTimeout: (id: number) => timers.delete(id), + chrome: { + runtime: { + sendMessage: async (message: { type: string }) => { + if (message.type === 'hd_status') return { ok: true, ready: true }; + sharingCalls += 1; + if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) { + stalled = true; + return new Promise(() => {}); + } + return { + ok: true, + sharing: { + client: { + linked: true, + connected: true, + address: 'host', + host: { dictionaryCount: 1 }, + }, + }, + }; + }, + }, + }, + }, + ); + for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve(); + assert.equal(stalled, true); + assert.equal(timers.size, 1); + for (const callback of timers.values()) callback(); + if (stalledCall === 'initial') { + await assert.rejects(result, /host did not respond/); + } else { + const status = parseHachidoriHostStatus(await result); + assert.equal(status.kind, 'disconnected'); + if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/); + } + assert.equal(timers.size, 0); + }); +} diff --git a/src/shared/hachidori-sharing.ts b/src/shared/hachidori-sharing.ts new file mode 100644 index 00000000..7594de2b --- /dev/null +++ b/src/shared/hachidori-sharing.ts @@ -0,0 +1,105 @@ +export type HachidoriHostStatus = + | { kind: 'local' } + | { + kind: 'connected'; + address: string; + name: string; + dictionaryCount: number; + // Advertised by the host in its sharing hello, e.g. HACHIDORI_LINKED_IMPORT_CAPABILITY. + capabilities: readonly string[]; + } + | { kind: 'disconnected'; address: string; message: string } + | { kind: 'unavailable'; message: string }; + +// Hosts with this capability (Hachidori 0.2.3+) accept dictionary ZIPs over the link. +export const HACHIDORI_LINKED_IMPORT_CAPABILITY = 'linked-import-v1'; + +export type HachidoriSharingRequest = + | { type: 'hd_sharing_status' } + | { type: 'hd_sharing_client_link'; address: string } + | { type: 'hd_sharing_client_unlink' }; + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus { + if (!object(reply) || reply.ok !== true || !object(reply.sharing)) { + throw new Error( + object(reply) && typeof reply.error === 'string' + ? reply.error + : 'Hachidori returned an invalid sharing status.', + ); + } + const client = reply.sharing.client; + if (!object(client) || typeof client.linked !== 'boolean') { + throw new Error('Hachidori returned an invalid host connection.'); + } + if (!client.linked) return { kind: 'local' }; + if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.'); + if (client.connected !== true) { + return { + kind: 'disconnected', + address: client.address, + message: + typeof client.error === 'string' + ? client.error + : 'The dictionary host is not reachable. Start it, then refresh status.', + }; + } + const host = client.host; + if ( + !object(host) || + typeof host.dictionaryCount !== 'number' || + !Number.isSafeInteger(host.dictionaryCount) || + host.dictionaryCount < 0 + ) { + throw new Error('Hachidori returned an invalid dictionary count.'); + } + return { + kind: 'connected', + address: client.address, + name: typeof host.name === 'string' ? host.name : 'Hachidori', + dictionaryCount: host.dictionaryCount, + capabilities: Array.isArray(host.capabilities) + ? host.capabilities.filter((entry): entry is string => typeof entry === 'string') + : [], + }; +} + +export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string { + return `(async () => { + const request = ${JSON.stringify(request)}; + const requestWithTimeout = async fields => { + let timer; + try { + return await Promise.race([ + chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }), + ]); + } finally { clearTimeout(timer); } + }; + const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields }); + const read = (target, type) => requestWithTimeout({ target, type }); + let reply = await send(request); + if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.'); + if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) { + try { + const engine = await read('hoshidicts-offscreen', 'hd_status'); + reply = await send({type: 'hd_sharing_status'}); + if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) { + const inventory = await read('hoshidicts-worker', 'hd_state_read'); + if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.'); + reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length; + } else if (reply.sharing?.client?.linked) { + reply.sharing.client.connected = false; + reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.'); + } + } catch (error) { + reply.sharing.client.connected = false; + reply.sharing.client.error = error.message; + } + } + return reply; + })()`; +} diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index df30469b..64ffaa83 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -9,6 +9,7 @@ import { getDefaultConfigDir, getDefaultConfigFilePaths, getSetupStatePath, + isSetupCompleted, normalizeSetupState, readSetupState, resolveDefaultMpvInstallPaths, @@ -312,3 +313,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults' ), }); }); + +test('setup state keeps backend identity and reads legacy completion as Yomitan', () => { + const legacy = { ...createDefaultSetupState(), status: 'completed' as const }; + assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true); + assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false); + const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' }); + assert.equal(hachidori?.dictionaryBackend, 'hachidori'); + assert.equal(isSetupCompleted(hachidori, 'hachidori'), true); + assert.equal(isSetupCompleted(hachidori, 'yomitan'), false); + assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null); +}); + +test('setup state remembers every backend that finished setup', () => { + const state = normalizeSetupState({ + ...createDefaultSetupState(), + status: 'completed', + dictionaryBackend: 'hachidori', + completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'], + }); + assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']); + assert.equal(isSetupCompleted(state, 'yomitan'), true); + assert.equal(isSetupCompleted(state, 'hachidori'), true); + const reopened = normalizeSetupState({ ...state, status: 'incomplete' }); + assert.equal(isSetupCompleted(reopened, 'hachidori'), false); + assert.equal(isSetupCompleted(reopened, 'yomitan'), true); + for (const status of ['incomplete', 'in_progress', 'cancelled']) { + const staleHistory = normalizeSetupState({ + ...state, + status, + completedDictionaryBackends: ['yomitan', 'hachidori'], + }); + assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false); + assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true); + } + assert.equal( + normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends, + undefined, + ); +}); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index 7370e732..23351043 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import type { DictionaryBackend } from '../types/config'; import os from 'node:os'; import path from 'node:path'; import { resolveConfigDir } from '../config/path-resolution'; @@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences { export interface SetupState { version: 4; + /** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */ + dictionaryBackend?: DictionaryBackend; + /** Backends whose setup finished at least once, so switching back never repeats setup. */ + completedDictionaryBackends?: DictionaryBackend[]; status: SetupStateStatus; completedAt: string | null; completionSource: SetupCompletionSource; @@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof return platform === 'win32' ? path.win32 : path.posix; } +function isDictionaryBackend(value: unknown): value is DictionaryBackend { + return value === 'yomitan' || value === 'hachidori'; +} + function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null { if ( (version !== 1 && version !== 2 && version !== 3 && version !== 4) || + (record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) || (status !== 'incomplete' && status !== 'in_progress' && status !== 'completed' && @@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null { return null; } + const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends) + ? record.completedDictionaryBackends.filter(isDictionaryBackend) + : []; + return { version: 4, + ...(isDictionaryBackend(record.dictionaryBackend) + ? { dictionaryBackend: record.dictionaryBackend } + : {}), + ...(completedDictionaryBackends.length > 0 + ? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] } + : {}), status, completedAt: typeof record.completedAt === 'string' ? record.completedAt : null, completionSource, @@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null { }; } -export function isSetupCompleted(state: SetupState | null | undefined): boolean { - return state?.status === 'completed'; +export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend { + return state.dictionaryBackend ?? 'yomitan'; +} + +/** Current status takes precedence; completion history applies only to other backends. */ +export function hasCompletedSetupForBackend( + state: SetupState, + dictionaryBackend: DictionaryBackend, +): boolean { + if (getSetupStateDictionaryBackend(state) === dictionaryBackend) { + return state.status === 'completed'; + } + return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend); +} + +export function isSetupCompleted( + state: SetupState | null | undefined, + dictionaryBackend?: DictionaryBackend, +): boolean { + if (!state) return false; + if (dictionaryBackend === undefined) return state.status === 'completed'; + return hasCompletedSetupForBackend(state, dictionaryBackend); } export function getDefaultConfigDir(options?: { diff --git a/src/stats-word-helper.ts b/src/stats-word-helper.ts index 080dcc3d..6e46a612 100644 --- a/src/stats-word-helper.ts +++ b/src/stats-word-helper.ts @@ -5,6 +5,14 @@ import type { BrowserWindow, Extension, Session } from 'electron'; import { ConfigService } from './config/service'; import { createLogger, setLogLevel } from './logger'; import { loadYomitanExtension } from './core/services/yomitan-extension-loader'; +import { + createHachidoriExtensionRuntime, + getHachidoriSession, +} from './core/services/hachidori-extension'; +import { + getPreferredYomitanAnkiServerUrl, + shouldForceOverrideYomitanAnkiServer, +} from './main/runtime/yomitan-anki-server'; import { addYomitanNoteViaSearch, getYomitanCurrentAnkiDeckName, @@ -104,27 +112,38 @@ async function main(): Promise { try { const configService = new ConfigService(userDataPath!); const config = configService.getConfig(); - const extension = await loadYomitanExtension({ - userDataPath: userDataPath!, - getYomitanParserWindow: () => yomitanParserWindow, - setYomitanParserWindow: (window) => { - yomitanParserWindow = window; - }, - setYomitanParserReadyPromise: (promise) => { - yomitanParserReadyPromise = promise; - }, - setYomitanParserInitPromise: (promise) => { - yomitanParserInitPromise = promise; - }, - setYomitanExtension: (extensionValue) => { - yomitanExt = extensionValue; - }, - setYomitanSession: (sessionValue) => { - yomitanSession = sessionValue; - }, - }); + // Mine with the same backend the app uses so notes come from the user's + // dictionaries and Anki templates, not an empty profile. + const extension = + config.dictionaryBackend === 'hachidori' + ? await createHachidoriExtensionRuntime(userDataPath!) + .ensureLoaded() + .then((loaded) => { + yomitanExt = loaded; + yomitanSession = getHachidoriSession(); + return loaded; + }) + : await loadYomitanExtension({ + userDataPath: userDataPath!, + getYomitanParserWindow: () => yomitanParserWindow, + setYomitanParserWindow: (window) => { + yomitanParserWindow = window; + }, + setYomitanParserReadyPromise: (promise) => { + yomitanParserReadyPromise = promise; + }, + setYomitanParserInitPromise: (promise) => { + yomitanParserInitPromise = promise; + }, + setYomitanExtension: (extensionValue) => { + yomitanExt = extensionValue; + }, + setYomitanSession: (sessionValue) => { + yomitanSession = sessionValue; + }, + }); if (!extension) { - throw new Error('Yomitan extension failed to load.'); + throw new Error(`${config.dictionaryBackend} extension failed to load.`); } const yomitanDeps = { @@ -156,17 +175,21 @@ async function main(): Promise { } await syncYomitanDefaultAnkiServer( - config.ankiConnect?.url || 'http://127.0.0.1:8765', + getPreferredYomitanAnkiServerUrl(config.ankiConnect), yomitanDeps, logger, - { forceOverride: true, deck: config.ankiConnect?.deck }, + { + forceOverride: shouldForceOverrideYomitanAnkiServer(config.ankiConnect), + deck: config.ankiConnect?.deck, + ankiConfig: config.ankiConnect, + }, ); const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger); const noteId = addResult.noteId; if (typeof noteId !== 'number') { - throw new Error('Yomitan failed to create note.'); + throw new Error(`${config.dictionaryBackend} failed to create note.`); } writeResponse(responsePath, { diff --git a/src/types/config.ts b/src/types/config.ts index d3752f1b..0dc878da 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -140,7 +140,10 @@ export interface RawShortcutsConfig extends ShortcutsConfig { openAnimetosho?: string | null; } +export type DictionaryBackend = 'yomitan' | 'hachidori'; + export interface Config { + dictionaryBackend?: DictionaryBackend; subtitlePosition?: SubtitlePosition; keybindings?: Keybinding[]; websocket?: WebSocketConfig; @@ -183,6 +186,7 @@ export interface Config { export type RawConfig = Config; export interface ResolvedConfig { + dictionaryBackend: DictionaryBackend; subtitlePosition: SubtitlePosition; keybindings: Keybinding[]; websocket: Required; diff --git a/vendor/hachidori b/vendor/hachidori new file mode 160000 index 00000000..fd71dcdd --- /dev/null +++ b/vendor/hachidori @@ -0,0 +1 @@ +Subproject commit fd71dcddba2f495dfdf1e06744f1d09ac2945d2e diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index 57516d3b..e95b0b66 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit 57516d3b7f3bffa604f575026cd39390067137ce +Subproject commit e95b0b6615dfd8e720af98596093fc90fb495e41