Compare commits

...
Author SHA1 Message Date
sudacode 52fc1b63e9 fix(launcher): validate login shell paths before execution
- Fall back to absolute default shells when configured values are relative
- Reject invalid shells before spawning them
2026-09-24 01:31:27 -07:00
sudacode da5074761f fix(launcher): load macOS login shell PATH for GUI launches 2026-09-24 01:10:15 -07:00
sudacode f7230db90c chore(release): prepare v0.20.0
- Build v0.20.0 changelog and release notes from changes/ fragments
- Remove package-size JSON reports and previous-release size comparisons from CI
- Download AUR release assets via curl with retries and skip publish on failure
- Update release docs, verification lanes, and workflow tests to match
2026-09-24 00:01:03 -07:00
sudacode 92ef305e8c chore(hachidori): update submodule to fork subminer 441971b
Pull in the fork fix that looks up popup definition text only when the
pointer is on a glyph, so hovering beside a short glossary row no longer
opens a nested popup for the next row's word.
2026-09-23 22:59:10 -07:00
sudacode 5096ca217b chore(release): remove package size reports
- Drop the afterAllArtifactBuild size reporting hook and the previous-release baseline downloads in CI
- Stop uploading, checksumming, and publishing package-size-*.json
- Keep the afterPack package content audit, now without size tracking
- Update RELEASING.md and the verification docs, and add a changelog fragment
2026-09-23 21:50:19 -07:00
sudacode 504faa51e0 fix(anki): honor overlay pause that races timing review setup
- Track overlay pauses per review request so a pause arriving while setup reads mpv's pause state still cancels the playback restore
- Ignore cancelPlaybackResume when no review request is in flight
- Add a test covering the pause-read race and reset between requests
2026-09-23 20:08:01 -07:00
sudacode c10174f9cd fix(dictionary): pin Hachidori extension ID with a static manifest key
Electron derives an unpacked extension's ID from its directory, so a changed
userData path gave Hachidori a new storage origin and an empty library. The
staged copy now carries a fixed key, like the bundled Yomitan.
2026-09-23 09:15:33 -07:00
sudacode 285b19ccc3 fix(ci): download AUR assets without release metadata 2026-09-23 01:43:40 -07:00
sudacode 33e694bc61 fix(anki): keep playback paused after overlay pause during timing review
- Treat overlay pause requests during an open media timing review as final: drop held resumes and the review's own playback restore
- Add cancelPlaybackResume to the media timing review runtime
- Test both paused and playing starting states
2026-09-23 01:36:11 -07:00
sudacode 430120cc63 fix(anki): hold playback paused while media timing review is open
- Defer overlay resume requests (popup closed, hover left) during an active review and apply them when the review closes
- Add deferPlaybackResume to the media timing review runtime and gate sendRendererMpvCommand on it
- Document the paused-playback behavior and add a changelog fragment
2026-09-23 00:32:35 -07:00
sudacode 294e3a777e chore(hachidori): update submodule to upstream d68089e
Merge 104 upstream Hachidori commits into the subminer fork, including
rebuilt engine artifacts and upstream glyph hover hit-testing.
2026-09-22 22:42:05 -07:00
sudacode c673b71053 fix(dictionary): restore Anki proxy marker when Hachidori sync fails
- Revert the proxy marker to its previous value when every settings write attempt fails, so it no longer claims a server that was never written
- Add a test for the all-writes-fail path
- Retry Windows managed runtime cleanup in the test while bun.exe stays locked after exit
2026-09-22 22:08:51 -07:00
sudacode d31a28c54f fix(dictionary): handle Hachidori extension load failures gracefully
- Catch errors from the Hachidori extension runtime and log them
- Clear the extension and session state and return null instead of rejecting
2026-09-22 21:54:49 -07:00
sudacode d100789232 fix(anki): regenerate sentence furigana when mined context changes
- Regenerate SentenceFurigana from the final sentence after timing-review expansion or stats-dashboard word mining
- Add a Yomitan parseText-based generator with a 10s timeout and escaped, highlighted output
- Clear stale furigana on failure so templates fall back to Sentence
- Keep existing furigana formatting when the sentence is unchanged
- Document the behavior in the anki-integration and immersion-tracking docs
2026-09-22 21:46:06 -07:00
sudacode 9a5fb02cbd fix(dictionary): sync Hachidori deck to ankiConnect.deck for polling
- Always set the first Hachidori Anki template deck to ankiConnect.deck so polling mode enriches Hachidori cards; other custom template settings stay intact
- Treat right-clicks retargeted to the Hachidori popup shadow host as overlay interactions so they no longer raise mpv or toggle pause
- Update anki-integration docs and changelog fragment
2026-09-22 21:43:53 -07:00
sudacode 2ba820f41a fix(dictionary): keep Hachidori lookup highlight on in SubMiner builds
Hachidori's overlay-mode first-install options turn off "Highlight the
word on the page" because GSM-style hosts screenshot the see-through
viewport. SubMiner captures media from mpv, and without the highlight
the subtitle sidebar gave no sign of which word was looked up or where
the match ended. The staged copy now seeds the highlight on; the fork
source is unchanged.
2026-09-22 21:06:19 -07:00
sudacode c39dbb5a8d fix(overlay): keep Hachidori attention from pausing playback
Hachidori's hachidori-popup-shown/hidden pair means "the reader needs
mouse events", which includes a left press anywhere on the overlay that
may start a selection. SubMiner treated it as popup visibility, so a
click paused and resumed mpv, and holding the button kept it paused.
The host element only exists after the first lookup, so the first
attempt (gate on an open popup pane when a host exists) still paused
on clicks before any lookup.

Once a Hachidori event has been seen, popup auto-pause now requires an
unhidden popup pane in the host's shadow root, and rechecks after each
successful lookup so drag-select popups still pause.
2026-09-22 21:06:19 -07:00
sudacode 2958591ec9 fix(dictionary): open Hachidori hover popups only over subtitle glyphs
Pin the Hachidori fork fix that stops the caret snapping into the line box
from looking up a subtitle's first word when the pointer is beside the line,
which kept the popup open and playback paused.
2026-09-22 20:39:35 -07:00
sudacode c9880efee3 test(anilist): apply sqlite pragmas in cover art fetcher tests
- Call applyPragmas before ensureSchema so test databases match the production setup
2026-09-22 19:30:31 -07:00
sudacode f225ac3c89 fix(dictionary): block --yomitan when an external profile is configured
- Keep read-only external-profile mode while Hachidori is active by checking the configured externalProfilePath
- Hachidori settings remain available
- Smoke check asserts the external-profile block, with a --bundled-yomitan path for the bundled case
- Update usage docs and changelog fragment
2026-09-22 19:03:39 -07:00
sudacode cacaa2b371 feat(overlay): add subtitle selection modal and Jellyfin 12 fixes
- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling
- Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info
- Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher
- Bump electron-builder to 26.16.1
- Condense and reconcile changelog fragments; update config example and docs
2026-09-22 18:51:59 -07:00
sudacode 9fe4c7eb33 fix(anki): preserve managed proxy state when settings sync fails 2026-09-22 15:49:24 -07:00
sudacode ab5e50adac fix(dictionary): complete Hachidori imports and mining integration 2026-09-22 15:01:23 -07:00
sudacode 15c74ef502 fix(dictionary): update Hachidori overlay sharing and test fixes 2026-09-22 14:26:32 -07:00
sudacode f0d3c95b4c fix(dictionary): pin Hachidori stats mining fix 2026-09-22 14:04:09 -07:00
sudacode d7e1d7edc6 refactor: configure Hachidori in the SubMiner build 2026-09-22 12:26:37 -07:00
sudacode 5354fdf7b9 refactor(dictionary): use Hachidori fork submodule and upstream API 2026-09-22 01:22:14 -07:00
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00
146 changed files with 5503 additions and 708 deletions
-36
View File
@@ -59,17 +59,6 @@ jobs:
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Build AppImage
run: bun run build:appimage
env:
@@ -98,7 +87,6 @@ jobs:
release/*.AppImage
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-macos:
@@ -159,17 +147,6 @@ jobs:
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Build signed + notarized macOS artifacts
run: bun run build:mac
env:
@@ -193,7 +170,6 @@ jobs:
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
build-windows:
@@ -235,17 +211,6 @@ jobs:
bun install --frozen-lockfile
bun run build
- name: Download previous package size reports
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p .tmp/package-baseline
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
if [ -n "$previous" ]; then
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
fi
- name: Verify managed Windows launcher
run: bun test src/main/runtime/managed-launcher.test.ts
@@ -270,5 +235,4 @@ jobs:
release/*.zip
release/latest*.yml
release/*.blockmap
release/package-size-*.json
if-no-files-found: error
-2
View File
@@ -105,7 +105,6 @@ jobs:
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -152,7 +151,6 @@ jobs:
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer
dist/launcher/subminer.cmd
)
+17 -10
View File
@@ -106,7 +106,6 @@ jobs:
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
files+=(release/package-size-*.json)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -171,7 +170,6 @@ jobs:
release/latest*.yml
release/*.blockmap
release/SHA256SUMS.txt
release/package-size-*.json
dist/launcher/subminer
dist/launcher/subminer.cmd
)
@@ -255,22 +253,31 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Download release assets for AUR
id: aur_assets
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
set -euo pipefail
version="$RELEASE_VERSION"
install -dm755 .tmp/aur-release-assets
gh release download "$version" \
--dir .tmp/aur-release-assets \
--pattern "SubMiner-${version#v}.AppImage" \
--pattern "subminer" \
--pattern "subminer-assets.tar.gz"
for asset in "SubMiner-${version#v}.AppImage" subminer subminer-assets.tar.gz; do
destination=".tmp/aur-release-assets/$asset"
if ! curl --fail --silent --show-error --location \
--retry 3 --retry-delay 1 --retry-all-errors \
--connect-timeout 30 --max-time 600 \
--output "$destination.partial" \
"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$version/$asset"; then
echo "::warning::Unable to download $asset after retries; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
mv "$destination.partial" "$destination"
done
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' && steps.aur_assets.outputs.skip != 'true'
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: |
@@ -287,7 +294,7 @@ jobs:
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
- name: Commit and push AUR update
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' && steps.aur_assets.outputs.skip != 'true'
working-directory: aur-subminer-bin
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
+2
View File
@@ -20,6 +20,8 @@ coverage/
/main-entry.js
/main-entry.js.map
/build/hachidori/
# Logs
*.log
npm-debug.log*
+4
View File
@@ -8,3 +8,7 @@
[submodule "vendor/subminer-yomitan"]
path = vendor/subminer-yomitan
url = https://github.com/ksyasuda/subminer-yomitan
[submodule "vendor/hachidori"]
path = vendor/hachidori
url = https://github.com/ksyasuda/hachidori.git
branch = subminer
+5 -2
View File
@@ -4,7 +4,7 @@
# SubMiner
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
@@ -25,6 +25,8 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
<div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
</div>
@@ -275,7 +277,8 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
## License
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Media timing review now keeps playback paused while it is open when the dictionary popup or subtitle hover pause ends, as with Hachidori popups closing when the review appears. Playback resumes after the review closes unless a dictionary popup is still open.
+14
View File
@@ -0,0 +1,14 @@
type: added
area: dictionary
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` opens Yomitan settings unless a read-only external Yomitan profile is configured. This restriction also applies with Hachidori active, without blocking Hachidori settings.
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Linked Docker hosts receive character dictionary uploads through `hachidori.externalHostManagementUrl`, retry busy imports, and replace the previous dictionary only after a successful import.
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates apart from the deck, which follows `ankiConnect.deck` so polling mode enriches Hachidori cards. Anki discovery retries after an unavailable connection.
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
- First-run setup remembers each backend that finished it, including when reopened for legacy plugin cleanup, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. A running Yomitan session keeps using its external profile until it restarts into Hachidori.
- Stats dashboard mining and deck lookup use the selected backend, with the Anki proxy enabled or disabled. Dashboard cards retain the selected history line and media instead of being processed again with the current mpv subtitle, with both Yomitan and Hachidori. Stats cards carry a `SubMiner::Stats` tag so polling also preserves their context. Hachidori word mining supplies dictionary aliases, IDs, and frequency metadata to its native Anki renderer. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining, media enrichment, and custom toolbar buttons stay in SubMiner, including when editing buttons while linked.
- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: dictionary
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: 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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: release
- Removed package-size JSON reports from future releases and their CI size comparisons. Package-content validation remains enabled.
+16
View File
@@ -6,6 +6,22 @@
*/
{
// ==========================================
// Dictionary Backend
// Select the dictionary lookup backend: yomitan or hachidori.
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
// ==========================================
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
// ==========================================
// Hachidori External Dictionary Imports
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// Used only while Hachidori is linked to an external host.
// ==========================================
"hachidori": {
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
+14
View File
@@ -21,6 +21,14 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
### Reusing SubMiner settings in Hachidori
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It sets the template's deck to `ankiConnect.deck` when one is configured, copies the configured tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set.
If the note type is unset, SubMiner looks for a unique match containing its configured word and sentence fields. Enabled Lapis, Kiku, or Senren integration narrows the search; Lapis uses its configured model name. A fresh mapping also receives Hachidori's matching preset for readings, definitions, and other recognized fields. If several note types match, choose one in Hachidori Settings. If Anki is closed, open Hachidori Settings again after starting Anki to retry.
The deck always follows `ankiConnect.deck`, as it does for Yomitan's mining deck, because polling mode only looks for new cards in that deck. Existing custom tags, field mappings, advanced templates, and additional templates stay intact. Apart from the deck, this fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior.
## Auto-enrichment transport
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
@@ -141,6 +149,8 @@ Field names are matched against your Anki note type case-insensitively (an exact
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
When Hachidori mines through SubMiner's Anki proxy, it prepares downloadable word audio before saving the note so the animation delay can be measured on the first mine. Configure a downloadable pronunciation source in Hachidori's Audio settings; browser speech cannot be saved into Anki by the SubMiner overlay. If pronunciation is unavailable, Hachidori reports a warning and the card has no word-audio hold.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
@@ -192,6 +202,8 @@ Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
Playback stays paused while the review is open, even if the dictionary popup or subtitle hover that paused it goes away. When the review closes, playback resumes if it was playing before the review or if the popup closed in the meantime. A dictionary popup that is still open keeps playback paused.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
@@ -317,6 +329,8 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
Field grouping runs when a new note is added with known duplicates. With the Hachidori backend that is the popup's **Add anyway** choice; **Overwrite** updates the existing note in place and only receives media enrichment.
```jsonc
"ankiConnect": {
"isKiku": {
+8
View File
@@ -171,6 +171,14 @@ The configuration file includes several main sections:
## Core settings
### Dictionary backend
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
`hachidori.externalHostManagementUrl` specifies the linked Docker host's HTTP(S) management origin for automatic character dictionary uploads and replacement. Use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup) for an example and [the generated configuration example](/config.example.jsonc) for the default.
### Logging
Control the minimum log level for runtime output:
+3 -1
View File
@@ -152,12 +152,14 @@ Stats server config lives under `stats`:
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Word** - looks up the word with the selected dictionary backend, Yomitan or Hachidori, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. The selected history line supplies the card's context even while another subtitle is playing in mpv. Hachidori uses its configured Anki template, dictionary aliases, and frequency metadata. Requires Anki and the selected backend's dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Stats cards also receive the `SubMiner::Stats` tag. SubMiner uses it to preserve their selected history context when detecting new cards through polling with the Anki proxy disabled.
Secondary subtitle text is stored alongside primary subtitles during playback, but the Search tab does not use it for display or matching.
### Word exclusion list
+16
View File
@@ -6,6 +6,22 @@
*/
{
// ==========================================
// Dictionary Backend
// Select the dictionary lookup backend: yomitan or hachidori.
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
// ==========================================
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
// ==========================================
// Hachidori External Dictionary Imports
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// Used only while Hachidori is linked to an external host.
// ==========================================
"hachidori": {
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
+2 -2
View File
@@ -15,7 +15,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
| Shortcut | Action | Scope | Configurable |
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
| `Alt+Shift+Y` | Open active dictionary settings | OS-global (registered with the OS) | Fixed (not configurable) |
::: tip
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
@@ -127,7 +127,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix - press `y
| `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay |
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
| `y-o` | Open Yomitan settings |
| `y-o` | Open active dictionary settings |
| `y-r` | Restart overlay |
| `y-c` | Check overlay status |
| `y-h` | Open session help |
+27 -4
View File
@@ -15,8 +15,8 @@ That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socke
Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine.
> [!IMPORTANT]
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
> See [Yomitan setup](#yomitan-setup) for details.
> SubMiner requires at least one dictionary in the selected lookup backend.
> See [Yomitan setup](#yomitan-setup) or [Hachidori setup](#hachidori-setup).
::: tip Anki card enrichment
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
@@ -174,6 +174,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle b
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --hachidori # Open Hachidori settings
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
@@ -208,7 +209,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
### Windows mpv shortcut
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
First-run setup creates the config file, then requires dictionaries in the selected backend before it can finish.
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
After setup completes, the shortcut is the normal Windows playback entry point.
@@ -314,6 +315,28 @@ For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yom
If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance.
### Hachidori setup
Set `dictionaryBackend` to `"hachidori"` in SubMiner settings or `config.jsonc`, then restart SubMiner. The tray's dictionary settings entry changes to **Open Hachidori Settings**. Switching to `"yomitan"` restores the Yomitan entry after restarting.
Open Hachidori settings with `subminer app --hachidori` or `SubMiner.AppImage --hachidori`. Import your dictionary ZIPs or use Hachidori's recommended dictionary installer, then configure its Anki templates. Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not transfer to Hachidori.
First-run setup also offers **Dictionary source → Use an external dictionary host → Link host**. Enable sharing in the other Hachidori app or browser, or start a compatible Docker dictionary host, then enter its sharing address, such as `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port. The external host section is collapsed until you expand it or a host is linked. Browser hosts need the browser, Hachidori extension, and relay running. Electron hosts need the host app and any required relay running. Docker hosts need the container running; no browser needs to stay open.
Setup checks the host connection and dictionary inventory before enabling Finish. Import at least one dictionary on the host and refresh status. The link persists across restarts. **Unlink and use local dictionaries** restores SubMiner's local library. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio/image processing remain local while linked. Dictionary settings and dictionary edits use the host. Frequency annotations use the frequencies returned with Hachidori dictionary entries. SubMiner keeps ranks found during scanning and queries the existing term-entry API for missing ranks. Words without a matching definition entry may remain unranked, even if a frequency dictionary contains them.
Both named settings flags work independently of the selected backend. Opening settings does not switch the overlay backend. The global dictionary-settings shortcut opens the selected backend.
Hachidori uses SubMiner's subtitle scanning, lookup counter, popup pause behavior, controller commands, character dictionaries, and Anki media enrichment. Keep SubMiner's AnkiConnect proxy enabled for screenshots and sentence audio. SubMiner routes Hachidori to that proxy when it is active; Hachidori's own screen recorder and screenshot capture are disabled in the embedded app.
For automatic character dictionary sync with a Docker host, set `hachidori.externalHostManagementUrl` to that same host's management origin, for example `"http://127.0.0.1:8780"`. This is separate from the WebSocket sharing address. SubMiner uploads the generated ZIP directly and replaces its previous dictionary after a successful import; busy imports are retried. Keep this URL pointed at the currently linked Docker host if you change hosts. An empty value disables external uploads and reports a configuration error when sync is attempted. Local Hachidori dictionaries do not need this setting. External browser/app hosts without the Docker management API do not support this automatic upload path.
Existing controls such as `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to the selected backend. Hachidori has one dictionary configuration, so character-dictionary profile scope applies to that configuration.
First-run setup remembers each backend that finished it, including when setup is reopened for legacy plugin cleanup. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. A running Yomitan session continues using its external profile until the restart. When `yomitan.externalProfilePath` is configured, `--yomitan` is disabled to preserve read-only external-profile mode, including while Hachidori is active. Hachidori settings remain available.
Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well.
### YouTube playback
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
@@ -408,7 +431,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
| Keybind | Action | Scope |
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
| `Alt+Shift+Y` | Open active dictionary settings | OS-global - registered with the system, works from any window |
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
+5 -15
View File
@@ -11,7 +11,7 @@
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it.
## Package contents and size checks
## Package contents checks
Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization
@@ -19,11 +19,9 @@ secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
artifacts built without it simply require users to set `tmdb.apiKey`).
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
TypeScript files, and nested test or fixture directories
fail the build. Size measurements are informational and do not block releases.
Each platform verifies its ASAR and external resources before signing. Missing
runtime assets, foreign SQLite/Koffi binaries, duplicate UI fonts, demo media,
source maps, TypeScript files, and nested test or fixture directories fail the build.
Current targets are Linux x64, macOS arm64, and Windows x64.
The runtime allowlist includes `dist/`, `stats/dist/`, and
@@ -34,14 +32,6 @@ exclusion-only platform list as a separate include-all matcher. Windows keeps
only its target Koffi binary; other platforms omit Koffi. Desktop UIs share the
original M PLUS 1 TTF in `dist/fonts/`.
`release/package-size-<platform>-<arch>.json` reports unpacked bytes, largest
files inside and outside ASAR, native binaries, and compressed artifact sizes.
Framework symlinks are not counted twice. Reports are checksummed and published.
CI downloads the preceding release's reports for comparison; older releases
without reports skip comparison. Review the inventory and reason for growth
when comparing releases. An AppImage normally
runs compressed; its extracted size is a separate measurement.
The shared workflow runs `bun run test:package <resources-directory>` with the
pinned Electron runtime and temporary user data. On headless Linux, prefix it
with `xvfb-run -a`. This checks packaged SQLite, Windows FFI loading/polling,
@@ -152,7 +142,7 @@ Notes:
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- AUR publish is best-effort: the workflow downloads the three known assets directly from the tagged release URLs, avoiding GitHub's sometimes-stale release asset listing. Downloads and SSH clone/push operations retry transient failures, then warn and skip AUR publication if retries are exhausted. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`latest*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- Release and prerelease workflows publish `subminer` for POSIX systems and `subminer.cmd` for Windows. Both locate a packaged app and use its private Bun runtime. Keep the corresponding-source archive named `bun-v1.3.5-source.tar.gz`.
+6
View File
@@ -46,6 +46,12 @@ Update checks and startup launcher migration share a serialized update-state sto
## Architecture Intent
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen popup auto-pause requires an unhidden popup pane in the host's shadow root and rechecks after each successful lookup. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
- Small units, explicit boundaries
- Composition over monoliths
- Pure helpers where possible
+4 -3
View File
@@ -60,9 +60,10 @@ bun run docs:build
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
On headless Linux: `xvfb-run -a bun run test:package release/linux-unpacked/resources`.
Content checks and informational size reporting run inside electron-builder hooks. See the
[release guide](../RELEASING.md#package-contents-and-size-checks) for size reports
and the installed-app verification checklist.
Content checks run inside the electron-builder afterPack hook. See the
[release guide](../RELEASING.md#package-contents-checks) for the
installed-app verification checklist.
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux. It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
- Coverage for the maintained source lane: `bun run test:coverage:src`
- Deep/local full gate: default handoff gate above
+7 -1
View File
@@ -26,7 +26,10 @@ import {
readSetupState,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
import {
hasLauncherExternalYomitanProfileConfig,
loadLauncherDictionaryBackend,
} from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const configDir = getLauncherConfigDir();
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
dictionaryBackend: loadLauncherDictionaryBackend(),
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
warn: (message) => log('warn', args.logLevel, message),
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
hasLegacyMpvPlugin: () =>
+7
View File
@@ -1,4 +1,5 @@
import { fail } from './log.js';
import type { DictionaryBackend } from '../src/types/config.js';
import type {
Args,
LauncherLoggingConfig,
@@ -100,6 +101,12 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
};
}
export function loadLauncherDictionaryBackend(): DictionaryBackend {
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
? 'hachidori'
: 'yomitan';
}
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
}
+4
View File
@@ -241,6 +241,10 @@ export function applyRootOptionsToArgs(
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.settings === true) parsed.settings = true;
if (options.yomitan === true || options.hachidori === true) {
parsed.appPassthrough = true;
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
}
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+2
View File
@@ -86,6 +86,8 @@ function applyRootOptions(program: Command): void {
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--settings', 'Open settings window')
.option('--yomitan', 'Open Yomitan settings window')
.option('--hachidori', 'Open Hachidori settings window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-H, --history', 'Browse local watch history')
+73 -20
View File
@@ -282,29 +282,31 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher settings option forwards app settings window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
for (const flag of ['--settings', '--yomitan', '--hachidori']) {
test(`launcher ${flag} forwards the matching app settings command`, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--settings'], env);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher([flag], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
});
});
});
}
test('launcher settings command forwards app settings window command', () => {
withTempDir((root) => {
@@ -1110,3 +1112,54 @@ test('classifyJellyfinChildSelection keeps container drilldown state instead of
id: 'season-2',
});
});
test('external Yomitan profile remains available while a running app awaits a backend switch', () => {
withTempDir((dir) => {
const env = makeTestEnv(dir, path.join(dir, 'config'));
const configPath = resolveConfigFilePath({
appDataDir: env.APPDATA,
xdgConfigHome: env.XDG_CONFIG_HOME,
homeDir: dir,
existsSync: () => false,
});
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
JSON.stringify({
dictionaryBackend: 'hachidori',
yomitan: { externalProfilePath: '/external/yomitan-profile' },
}),
);
const result = spawnSync(
process.execPath,
[
'--eval',
`
import assert from 'node:assert/strict';
import { hasLauncherExternalYomitanProfileConfig } from './launcher/config.ts';
import { ensureLauncherSetupReady } from './launcher/setup-gate.ts';
import { createDefaultSetupState } from './src/shared/setup-state.ts';
for (const running of [true, false]) {
let launches = 0;
let tick = 0;
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
isAppRunning: async () => running,
readSetupState: () => ({ ...createDefaultSetupState(), dictionaryBackend: 'yomitan' }),
isExternalYomitanConfigured: hasLauncherExternalYomitanProfileConfig,
launchSetupApp: () => { launches += 1; },
sleep: async () => {},
now: () => tick++,
timeoutMs: 2,
pollIntervalMs: 1,
});
assert.equal(ready, running);
assert.equal(launches, running ? 0 : 1);
}
`,
],
{ cwd: process.cwd(), env, encoding: 'utf8', timeout: LAUNCHER_RUN_TIMEOUT_MS },
);
assert.equal(result.status, 0, result.stderr);
});
});
+9
View File
@@ -344,3 +344,12 @@ test('parseArgs requires an explicit logs action', () => {
assert.equal(exit.code, 1);
assert.match(exit.stderr, /Logs command requires -e or --export/);
});
for (const flag of ['--yomitan', '--hachidori']) {
test(`parseArgs forwards ${flag} to the app`, () => {
const parsed = parseArgs([flag], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, [flag]);
assert.equal(parsed.settings, false);
});
}
+104 -2
View File
@@ -1,7 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
import {
ensureLauncherSetupReady,
resolveLauncherGateBackend,
waitForSetupCompletion,
} from './setup-gate';
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
@@ -295,3 +299,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
assert.equal(result, true);
});
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
let launched = 0;
let polls = 0;
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
readSetupState: () => state,
isExternalYomitanConfigured: () => true,
launchSetupApp: () => {
launched += 1;
},
sleep: async () => {
polls += 1;
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
},
now: () => polls,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
assert.equal(launched, 1);
assert.equal(polls, 1);
});
test('matching Hachidori completion resumes playback without launching setup', async () => {
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
readSetupState: () => ({
...createDefaultSetupState(),
dictionaryBackend: 'hachidori',
status: 'completed',
lastSeenYomitanDictionaryCount: 1,
}),
launchSetupApp: () => assert.fail('setup should not open'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
test('a backend that finished setup earlier passes the gate after switching back', async () => {
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'yomitan',
readSetupState: () => ({
...createDefaultSetupState(),
dictionaryBackend: 'hachidori',
status: 'incomplete',
completedDictionaryBackends: ['yomitan'],
}),
launchSetupApp: () => assert.fail('setup should not open'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
test('gate follows the running app backend until it restarts into the configured one', async () => {
const warnings: string[] = [];
const state = {
...createDefaultSetupState(),
dictionaryBackend: 'yomitan' as const,
status: 'completed' as const,
};
assert.equal(
await resolveLauncherGateBackend({
configuredBackend: 'hachidori',
state,
isAppRunning: async () => true,
warn: (message) => warnings.push(message),
}),
'yomitan',
);
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
assert.equal(
await resolveLauncherGateBackend({
configuredBackend: 'hachidori',
state,
isAppRunning: async () => false,
}),
'hachidori',
);
const ready = await ensureLauncherSetupReady({
dictionaryBackend: 'hachidori',
isAppRunning: async () => true,
readSetupState: () => state,
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5,
pollIntervalMs: 1,
});
assert.equal(ready, true);
});
+47 -6
View File
@@ -1,7 +1,13 @@
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
import type { DictionaryBackend } from '../src/types/config.js';
import {
getSetupStateDictionaryBackend,
isSetupCompleted,
type SetupState,
} from '../src/shared/setup-state.js';
export async function waitForSetupCompletion(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (isSetupCompleted(state)) {
if (isSetupCompleted(state, deps.dictionaryBackend)) {
return 'completed';
}
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
export async function waitForLegacyMpvPluginPromptResolution(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
initialState?: SetupState | null;
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
const initialCompleted = isSetupCompleted(deps.initialState);
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
const initialCompletedAt = deps.initialState?.completedAt ?? null;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (
isSetupCompleted(state) &&
isSetupCompleted(state, deps.dictionaryBackend) &&
(!initialCompleted || state?.completedAt !== initialCompletedAt)
) {
return 'acknowledged';
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
return 'timeout';
}
/**
* The app pins its dictionary backend at startup while the config file can change
* underneath it. When an app is already running, gate on the backend it recorded
* in the setup state rather than the config value it has not restarted into.
*/
export async function resolveLauncherGateBackend(deps: {
configuredBackend: DictionaryBackend;
state: SetupState | null;
isAppRunning?: () => Promise<boolean>;
warn?: (message: string) => void;
}): Promise<DictionaryBackend> {
const runningBackend = deps.state
? getSetupStateDictionaryBackend(deps.state)
: deps.configuredBackend;
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
return deps.configuredBackend;
}
deps.warn?.(
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
);
return runningBackend;
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
dictionaryBackend?: DictionaryBackend;
isAppRunning?: () => Promise<boolean>;
warn?: (message: string) => void;
isExternalYomitanConfigured?: () => boolean;
hasLegacyMpvPlugin?: () => boolean;
launchSetupApp: () => void;
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
pollIntervalMs: number;
}): Promise<boolean> {
const initialState = deps.readSetupState();
const dictionaryBackend = await resolveLauncherGateBackend({
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
state: initialState,
isAppRunning: deps.isAppRunning,
warn: deps.warn,
});
let setupLaunched = false;
const launchSetupApp = () => {
if (setupLaunched) return;
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
launchSetupApp();
const result = await waitForLegacyMpvPluginPromptResolution({
readSetupState: deps.readSetupState,
dictionaryBackend,
sleep: deps.sleep,
now: deps.now,
timeoutMs: deps.timeoutMs,
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
}
}
if (deps.isExternalYomitanConfigured?.()) {
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
return true;
}
const stateAfterLegacyPrompt = deps.readSetupState();
if (isSetupCompleted(stateAfterLegacyPrompt)) {
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
return true;
}
launchSetupApp();
const result = await waitForSetupCompletion({
...deps,
dictionaryBackend,
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
});
return result === 'completed';
+9 -4
View File
@@ -12,6 +12,7 @@
"get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
"test:dictionary:electron": "bun scripts/run-dictionary-settings-smoke.mjs && bun scripts/run-dictionary-settings-smoke.mjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs",
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
@@ -21,7 +22,7 @@
"build:launcher": "bun run scripts/build-launcher.ts",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
@@ -81,7 +82,8 @@
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
"test:package": "bun scripts/run-package-smoke.mjs"
"test:package": "bun scripts/run-package-smoke.mjs",
"build:hachidori": "bun scripts/build-hachidori.mjs"
},
"overrides": {
"@xmldom/xmldom": "0.8.15",
@@ -232,6 +234,10 @@
"from": "build/yomitan",
"to": "yomitan"
},
{
"from": "build/hachidori",
"to": "hachidori"
},
{
"from": "vendor/yomitan-jlpt-vocab",
"to": "yomitan-jlpt-vocab"
@@ -269,8 +275,7 @@
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
}
],
"afterAllArtifactBuild": "scripts/package-audit.cjs"
]
},
"patchedDependencies": {
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
-159
View File
@@ -1,159 +0,0 @@
## Highlights
### Added
- **Japanese Subtitle Generation**:
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
- **Subtitle Selection Modal**:
- An optional modal for choosing primary and secondary mpv subtitle tracks.
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
- Single-key actions take priority over configured key sequence prefixes.
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
- **Subtitle Sidebar Copy**:
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
- Selecting text does not seek playback and does not require mining a card.
- **Media Timing Review Screenshot Picker**:
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
- Step through decoded frames one at a time to get the exact frame you want.
- Works with local video and with seekable remote streams such as Jellyfin.
- **mpv Keybindings in the Overlay**:
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
- SubMiner controls and bindings you explicitly disabled take precedence.
- These bindings apply only to the current session and are not listed in the help menu.
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
- **TMDB Live-Action Library**:
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
- **YouTube Library Kind**:
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
- New All Titles, Anime, and YouTube Library filters.
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
### Changed
- **Launcher Uses Bundled Bun**:
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
- Recognized legacy launchers migrate automatically.
- Windows gets a `subminer.cmd` launcher download.
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
- **Faster Sync Transfers**:
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
- Older peers still work without the upload cache.
- Transfers abort after 30 minutes.
- **Stats Server Request Safety**:
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
- The in-app stats overlay now loads from the local server, so it gets the same protection.
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
- **Smaller Downloads**:
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
- All windows now share one Japanese UI font.
- Release builds publish package size reports that compare against the previous release.
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
### Fixed
- **Jellyfin 12 Compatibility**:
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
- **Jellyfin Privacy and Playback**:
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
- **Anki Mining**:
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
- **Stats Server Stability**:
- A port conflict is now reported in a status notification instead of crashing SubMiner.
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
- Shutdown waits only a limited time for active requests to finish.
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
- **Subtitle Sidebar**:
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
- **Settings Save Feedback**:
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
- **Overlay Windows**:
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
### Docs
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
- **Jellyfin**:
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
- **Stats Library**:
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
- Documented YouTube channel filtering and video statistics in the Library.
- **Mining**:
- Documented choosing the screenshot separately in media timing review.
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
## What's Changed
- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
- fix(startup): create config directory before singleton lock by @ksyasuda in #242
- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
- build(release): reduce package size and report release sizes by @ksyasuda in #244
- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
- feat(jimaku): add live-action subtitle search by @ksyasuda in #251
- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
- fix(jellyfin): support modern authentication by @ksyasuda in #264
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
## New Contributors
- @aalhendi made their first contribution in #254
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file
Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.
The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'bun:test';
test.each([false, true])(
'AUR downloads handle empty release metadata, unavailable=%s',
async (unavailable) => {
const workflow = await readFile(
new URL('../.github/workflows/release.yml', import.meta.url),
'utf8',
);
const step = workflow
.split(' - name: Download release assets for AUR\n')[1]
?.split('\n - name:')[0];
const script = step?.split(' run: |\n')[1]?.replace(/^ /gm, '');
assert.ok(script, 'AUR download step must have a shell script');
const workspace = await mkdtemp(path.join(os.tmpdir(), 'subminer-aur-download-'));
const requests: string[] = [];
const files = new Map([
['SubMiner-0.20.0.AppImage', 'appimage bytes'],
['subminer', 'launcher bytes'],
['subminer-assets.tar.gz', 'optional assets bytes'],
]);
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(request) {
const pathname = new URL(request.url).pathname;
requests.push(pathname);
if (unavailable || requests.length === 1) return new Response('try again', { status: 503 });
const name = pathname.split('/').at(-1);
const body = name ? files.get(name) : undefined;
return new Response(body ?? 'not found', { status: body ? 200 : 404 });
},
});
try {
const bin = path.join(workspace, 'bin');
await mkdir(bin);
await writeFile(
path.join(bin, 'gh'),
'#!/bin/sh\necho "no assets to download" >&2\nexit 1\n',
{ mode: 0o755 },
);
const output = path.join(workspace, 'output');
const proc = Bun.spawn(['bash', '-c', script], {
cwd: workspace,
env: {
...process.env,
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
RELEASE_VERSION: 'v0.20.0',
GITHUB_SERVER_URL: server.url.origin,
GITHUB_REPOSITORY: 'ksyasuda/SubMiner',
GITHUB_OUTPUT: output,
},
stdout: 'pipe',
stderr: 'pipe',
});
const [status, stderr, stdout] = await Promise.all([
proc.exited,
new Response(proc.stderr).text(),
new Response(proc.stdout).text(),
]);
assert.equal(status, 0, stderr);
if (unavailable) {
assert.equal(requests.length, 4, 'failed downloads stop after three retries');
assert.match(await readFile(output, 'utf8'), /^skip=true$/m);
assert.match(stdout, /::warning::Unable to download/);
await assert.rejects(
readFile(path.join(workspace, '.tmp/aur-release-assets/SubMiner-0.20.0.AppImage')),
{ code: 'ENOENT' },
);
return;
}
for (const [name, body] of files) {
assert.equal(
await readFile(path.join(workspace, '.tmp/aur-release-assets', name), 'utf8'),
body,
);
assert.ok(requests.includes(`/ksyasuda/SubMiner/releases/download/v0.20.0/${name}`));
}
assert.equal(requests.length, 4, 'the first failed download must be retried');
assert.match(await readFile(output, 'utf8'), /^skip=false$/m);
} finally {
server.stop(true);
await rm(workspace, { recursive: true, force: true });
}
},
15_000,
);
+69
View File
@@ -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: !IS_FIREFOX,', 'customJavaScript: false,'],
],
// Overlay hosts seed the lookup highlight off because their Anki screenshot is
// the see-through viewport. SubMiner captures media from mpv, and without the
// highlight the sidebar shows nothing for the word being looked up.
'setup-state.js': [
[
'lookupMode: "hover",\n sourceHighlightEnabled: false,',
'lookupMode: "hover",\n sourceHighlightEnabled: true,',
],
],
};
for (const [file, replacements] of Object.entries(hostConfiguration)) {
const filePath = path.join(output, file);
let text = fs.readFileSync(filePath, 'utf8');
for (const [original, replacement] of replacements) {
if (!text.includes(original))
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
text = text.replace(original, replacement);
}
fs.writeFileSync(filePath, text);
}
manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts');
// 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`);
+53
View File
@@ -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');
});
+139
View File
@@ -0,0 +1,139 @@
// Exercises the real entry point with disposable Linux config and data directories.
const { app, BrowserWindow, session, shell } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
if (process.platform !== 'linux')
throw new Error('This app-entry smoke requires Linux XDG isolation.');
const root = process.cwd();
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
const externalYomitanProfile =
backend === 'hachidori' && !process.argv.includes('--bundled-yomitan');
const profile = process.env.SUBMINER_DICTIONARY_SMOKE_DATA;
assert(
profile && fs.existsSync(profile),
'Use bun run test:dictionary:electron for profile isolation',
);
process.env.XDG_CONFIG_HOME = profile;
process.env.XDG_DATA_HOME = path.join(profile, 'data');
fs.mkdirSync(path.join(profile, 'SubMiner'));
fs.writeFileSync(
path.join(profile, 'SubMiner', 'config.json'),
JSON.stringify({
dictionaryBackend: backend,
// External Yomitan profiles must not block Hachidori settings.
yomitan: {
externalProfilePath: externalYomitanProfile ? path.join(profile, 'external-yomitan') : '',
},
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
ankiConnect: { enabled: false },
startupWarmups: { lowPowerMode: true },
discordPresence: { enabled: false },
updates: { enabled: false },
}),
);
const openedLinks = [];
shell.openExternal = async (url) => {
openedLinks.push(url);
};
app.setAppPath(root);
app.getVersion = () => require(path.join(root, 'package.json')).version;
process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log');
app.commandLine.appendSwitch('ozone-platform', 'x11');
app.commandLine.appendSwitch('disable-gpu');
app.commandLine.appendSwitch('disable-dev-shm-usage');
process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug'];
require(path.join(root, 'dist/main-entry.js'));
const deadline = setTimeout(() => {
console.error(
'FAIL timeout',
BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
);
finish(1);
}, 60000);
function finish(exitCode) {
clearTimeout(deadline);
app.exit(exitCode);
}
(async () => {
await app.whenReady();
let window;
for (let i = 0; i < 300; i++) {
window = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Hachidori') && w.isVisible(),
);
if (window) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(window, 'Hachidori settings window opens from actual app flag');
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
assert.match(window.webContents.getURL(), /settings.html/);
const status = await window.webContents.executeJavaScript(
`chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`,
);
assert.equal(status.ok, true);
assert.equal(status.ready, true);
console.log(
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
);
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
if (externalYomitanProfile) {
const blockedMessage =
'Yomitan settings unavailable while using read-only external-profile mode.';
for (let i = 0; i < 100; i++) {
if (fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage)) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(
fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage),
'--yomitan is rejected when an external Yomitan profile is configured',
);
assert.equal(session.defaultSession.extensions.getAllExtensions().length, 0);
assert.ok(!BrowserWindow.getAllWindows().some((w) => w.getTitle().includes('Yomitan')));
console.log('PASS external Yomitan settings blocked while Hachidori settings remain available');
} else {
let yomi;
for (let i = 0; i < 200; i++) {
yomi = BrowserWindow.getAllWindows().find(
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
);
if (yomi) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(yomi, 'inactive --yomitan settings opens');
assert.equal(yomi.webContents.session, session.defaultSession);
}
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
let overlay;
for (let i = 0; i < 200; i++) {
overlay = BrowserWindow.getAllWindows().find((w) =>
w.webContents.getURL().includes('/renderer/index.html?'),
);
if (overlay && !overlay.webContents.isLoading()) break;
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(overlay, 'actual overlay initialized');
assert.equal(
overlay.webContents.session,
backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession,
);
const requestLink = (url) =>
overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{
const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000);
window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true});
window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}}));
})`);
assert.equal((await requestLink('file:///tmp/private')).ok, false);
assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori');
assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []);
console.log(
`PASS ${backend} overlay session, independent settings windows, real preload external link bridge`,
);
finish(0);
})().catch((error) => {
console.error(error);
finish(1);
});
+239
View File
@@ -0,0 +1,239 @@
// Run after bun run build. This uses a temporary profile and small local ZIPs.
const { app, BrowserWindow, protocol, session } = require('electron');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const root = path.resolve(__dirname, '..');
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
protocol.registerSchemesAsPrivileged([
{
scheme: 'chrome-extension',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
bypassCSP: true,
},
},
]);
app.setPath('userData', profile);
app.setAppPath(root);
app.disableHardwareAcceleration();
app.on('window-all-closed', () => {});
const deadline = setTimeout(() => {
console.error('Hachidori parser verification timed out', profile);
app.exit(1);
}, 120_000);
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
const zipPath = path.join(profile, name + '.zip');
writeStoredZip(zipPath, [
{
name: 'index.json',
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
},
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
]);
return zipPath;
}
app
.whenReady()
.then(async () => {
const targetSession = session.fromPartition('persist:hachidori-check');
const extension = await targetSession.extensions.loadExtension(
path.join(root, 'build/hachidori'),
{ allowFileAccess: true },
);
// Keep a host window alive while the dictionary importer opens/closes its
// temporary settings windows, matching the running app's window lifecycle.
const host = new BrowserWindow({
show: false,
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
});
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
let parserWindow = null;
let readyPromise = null;
let initPromise = null;
const deps = {
getYomitanExt: () => extension,
getYomitanSession: () => targetSession,
getYomitanParserWindow: () => parserWindow,
setYomitanParserWindow: (value) => {
parserWindow = value;
},
getYomitanParserReadyPromise: () => readyPromise,
setYomitanParserReadyPromise: (value) => {
readyPromise = value;
},
getYomitanParserInitPromise: () => initPromise,
setYomitanParserInitPromise: (value) => {
initPromise = value;
},
};
const errors = [];
const logger = {
error: (...args) => {
errors.push(args);
console.error(...args);
},
};
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
forceOverride: true,
deck: 'Test Mining',
}),
true,
);
// Exercise discovery and revisioned options writes in the real extension,
// without creating a note or changing the user's Anki collection.
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
const metadataServer = http.createServer((request, response) => {
let body = '';
request.on('data', (chunk) => {
body += chunk;
});
request.on('end', () => {
const message = JSON.parse(body);
assert.equal(message.action, 'multi');
const result = message.params.actions.map(({ action }) => ({
result:
action === 'deckNames'
? ['Test Mining']
: action === 'modelNames'
? ['Custom Japanese']
: fields,
error: null,
}));
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ result, error: null }));
});
});
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
try {
const url = `http://127.0.0.1:${metadataServer.address().port}`;
const ankiConfig = {
tags: ['SubMiner', 'Autofill'],
fields: {
word: 'Term',
sentence: 'Context',
wordAudio: 'Pronunciation',
image: 'Image',
},
};
assert.equal(
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
forceOverride: true,
deck: 'Test Mining',
ankiConfig,
}),
true,
);
const anki = await parserWindow.webContents.executeJavaScript(
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
);
assert.equal(anki.model, 'Custom Japanese');
assert.equal(anki.deck, 'Test Mining');
assert.deepEqual(anki.tags, ankiConfig.tags);
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
} finally {
await new Promise((resolve) => metadataServer.close(resolve));
}
const archives = [
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
]),
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
]),
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
['頻度だけ', 'freq', 17],
]),
fixture(
'occurrences',
'SubMiner Test Occurrences',
'term_meta_bank_1.json',
[['頻度だけ', 'freq', 9000]],
'occurrence-based',
),
];
for (const archive of archives)
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
assert.equal(dictionaries.length, 4);
parser.clearYomitanParserCachesForWindow(parserWindow);
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
});
assert.equal(tokens[0].isNameMatch, true);
assert.equal(tokens[1].headword, '食べる');
assert.equal(tokens[1].reading, 'たべた');
assert.equal(tokens[1].startPos, 4);
assert.equal(tokens[1].endPos, 7);
assert.equal(tokens[1].frequencyRank, 42);
assert.deepEqual(tokens[1].wordClasses, ['v1']);
const exact = await parser.requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'たべる' }],
deps,
logger,
);
assert.equal(exact.length, 1);
assert.equal(exact[0].frequency, 42);
assert.equal(exact[0].reading, 'たべる');
assert.equal(exact[0].hasReading, false);
const otherReading = await parser.requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'べつのよみ' }],
deps,
logger,
);
// The shared frequency pipeline retries a missing reading as a term-only query.
assert.equal(otherReading[0]?.frequency, 42);
const unmatched = await parser.requestYomitanTermFrequencies(
[{ term: '頻度だけ', reading: null }],
deps,
logger,
);
assert.deepEqual(unmatched, []);
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
assert.equal(
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
true,
);
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
const proxyState = await host.webContents.executeJavaScript(
"chrome.storage.local.get('subminerAnkiProxyUrl')",
);
assert.equal(proxyState.subminerAnkiProxyUrl, null);
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
}
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
assert.equal(errors.length, 0);
console.log(
'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
);
clearTimeout(deadline);
app.exit(0);
})
.catch((error) => {
console.error(error);
clearTimeout(deadline);
app.exit(1);
});
+137
View File
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import '../vendor/hachidori/extension/reader-options.js';
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
async function mine({ proxy = true, audioFails = false } = {}) {
const events: string[] = [];
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
let fields: Record<string, string> = {};
let initialAudio = '';
let stored = false;
const gateway = createAnkiGateway({
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
fetch: async (_url: string, options: RequestInit) => {
const { action, params } = JSON.parse(String(options.body));
events.push(action);
let result: unknown;
switch (action) {
case 'multi':
result = [
{ result: ['Default'], error: null },
{ result: ['Basic'], error: null },
{ result: ['Expression', 'ExpressionAudio'], error: null },
];
break;
case 'canAddNotes':
result = [true];
break;
case 'canAddNotesWithErrorDetail':
result = [{ canAdd: true, error: null }];
break;
case 'getMediaFilesNames':
result = stored ? [filename] : [];
break;
case 'storeMediaFile':
stored = true;
result = filename;
break;
case 'addNote':
fields = { ...params.note.fields };
initialAudio = fields.ExpressionAudio ?? '';
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
result = 123;
break;
case 'notesInfo':
result = [
{
noteId: 123,
fields: Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, { value }]),
),
},
];
break;
case 'updateNoteFields':
Object.assign(fields, params.note.fields);
result = null;
break;
default:
throw new Error(`Unexpected action: ${action}`);
}
return Response.json({ result, error: null });
},
});
const service = createAnkiWorkerService({
gateway,
readOptions: async () => ({
anki: {
url: 'http://127.0.0.1:8766',
apiKey: '',
templates: [
{
id: 'default',
name: 'Default',
deck: 'Default',
model: 'Basic',
tags: [],
fields: {},
duplicateScope: 'model',
duplicateBehavior: 'prevent',
captureScreenshot: false,
fieldTemplates: {
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
},
},
],
},
audioSources: [
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
],
mediaCapture: { enabled: false },
}),
readDictionaries: async () => [],
engine: async () => ({ ready: true, loading: false, generation: 1 }),
offscreen: async (message: { type: string; audio?: string }) => {
if (message.type === 'hd_anki_audio') {
events.push('pronunciation');
if (audioFails) throw new Error('No pronunciation available');
return { filename, data: 'YXVkaW8=' };
}
return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] };
},
duplicateIndex: { source: async () => null, recordWrite: async () => {} },
});
const status = await service.status();
assert.equal(status.available, true, status.error);
const result = await service.submit({
configKey: status.configKey,
generation: 1,
term: { expression: '猫', reading: 'ねこ' },
});
return { result, initialAudio, fields, events, filename };
}
test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => {
const value = await mine();
assert.equal(value.result.state, 'added');
assert.equal(value.initialAudio, `[sound:${value.filename}]`);
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
});
test('direct Hachidori keeps deferred pronunciation', async () => {
const value = await mine({ proxy: false });
assert.equal(value.initialAudio, '');
assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`);
});
test('unavailable pronunciation remains a warning without a late audio write', async () => {
const value = await mine({ audioFails: true });
assert.equal(value.result.state, 'added');
assert.match(value.result.warnings.join(' '), /No pronunciation available/);
assert.equal(value.initialAudio, '');
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
assert.equal(value.events.includes('updateNoteFields'), false);
});
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
const bridge = readFileSync(
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
'utf8',
);
function run(code: string) {
runInNewContext(`${bridge}\n${code}`, {
window: new EventTarget(),
EventTarget,
CustomEvent,
assert,
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
KeyboardEvent: class {
constructor(_type: string, options: KeyboardEventInit = {}) {
Object.assign(this, options);
}
},
});
}
test('Hachidori marks popup state independently from successful lookups', () => {
run(`
const events = [];
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
window.addEventListener(name, () => events.push(name));
}
const attributes = new Map();
const host = { setAttribute: (name, value) => attributes.set(name, value) };
SubMinerHachidori.markHost(host, true);
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
assert.equal(events.join(','), '');
SubMinerHachidori.lookup();
SubMinerHachidori.markHost(host, false);
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
assert.equal(events.join(','), 'subminer-yomitan-lookup');
`);
});
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
run(`
const result = (id, matched, dictionary, options = {}) => ({
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
});
const character = characterPrefix + ' - Current show';
const longer = result('longer', '花子さん', 'General');
const normal = result('normal', '花子', 'General');
const person = result('person', '花子', character);
const shorter = result('shorter', '花', character);
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
const merged = result('merged', '花子', 'General');
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
assert.equal(merged.term.glossaries[0].dictionary, 'General');
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
const transformed = { ...person, ...change };
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
}
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
const aliased = result('aliased', '花子', 'Imported character data');
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
`);
});
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
const requests: Array<{ url: string; body: string }> = [];
const proxy = 'http://127.0.0.1:8766';
const gateway = createAnkiGateway({
readSubminerProxyUrl: async () => proxy,
fetch: async (url: string, options: RequestInit) => {
assert.equal(typeof options.body, 'string');
requests.push({ url, body: String(options.body) });
return Response.json({ result: 123, error: null });
},
});
const params = {
note: { fields: { Expression: '花子' } },
subminerDuplicateNoteIds: [456],
subminerEnrich: true,
};
await gateway.invoke('addNote', params, '', 1000, proxy);
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
assert.deepEqual(
requests.map((request) => JSON.parse(request.body).params),
[params, { note: params.note }],
);
assert.equal(params.subminerEnrich, true);
const direct = createAnkiGateway({
readSubminerProxyUrl: async () => null,
fetch: async (_url: string, options: RequestInit) => {
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
return Response.json({ result: null, error: null });
},
});
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
});
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
run(`
const calls = [];
const disconnect = SubMinerHachidori.connect({
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
scroll: (x, y) => calls.push(x + ':' + y),
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
});
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
send(null);
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
send({ type: 'mineSelected' });
send({ type: 'playCurrentAudio' });
send({ type: 'scanSelectedText' });
send({ type: 'cycleAudioSource', direction: -1 });
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
send({ type: 'setVisible', visible: false });
send({ type: 'clearActiveTextSource' });
disconnect();
send({ type: 'mineSelected' });
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
`);
});
+16 -96
View File
@@ -4,8 +4,6 @@ const assert = require('node:assert/strict');
const asar = require('@electron/asar');
const { Arch } = require('builder-util');
const MIB = 1024 * 1024;
const currentReports = new Set();
const REQUIRED_APP_FILES = [
'package.json',
'LICENSE',
@@ -27,6 +25,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',
@@ -41,13 +47,13 @@ const REQUIRED_RESOURCES = [
'CHANGELOG.md',
];
// Do not follow framework symlinks or count ASAR unpacked entries twice.
// Skip symlinks when checking resource contents.
function listFiles(root, prefix = '') {
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isSymbolicLink()) return [];
if (entry.isDirectory()) return listFiles(root, name);
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
return [name];
});
}
@@ -58,7 +64,7 @@ function listAppFiles(archive) {
const native = entry.replace(/^[\\/]/, '');
const stat = asar.statFile(archive, native);
const name = native.replaceAll('\\', '/');
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
return 'size' in stat ? [name] : [];
});
}
@@ -99,13 +105,13 @@ function verifyAppPath(name, platform, arch) {
function verifyContents(archive, resources, platform, arch) {
const entries = listAppFiles(archive);
const names = new Set(entries.map((entry) => entry.path));
const names = new Set(entries);
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
for (const name of REQUIRED_RESOURCES) {
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
}
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
for (const name of entries) verifyAppPath(name, platform, arch);
const libsqlPlatform = {
linux: `linux-${arch}-gnu`,
darwin: `darwin-${arch}`,
@@ -129,7 +135,7 @@ function verifyContents(archive, resources, platform, arch) {
}
}
for (const name of listFiles(path.join(resources, 'assets'))) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
assert(!name.startsWith('minecard'), `Demo media shipped: ${name}`);
}
for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
@@ -147,92 +153,8 @@ async function auditPackage(context) {
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
: context.appOutDir;
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
const files = listFiles(appRoot);
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
const report = {
version: context.packager.appInfo.version,
platform,
arch,
unpackedBytes,
appDirectory: path.relative(context.outDir, appRoot),
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
artifacts: [],
};
const output = path.join(context.outDir, `package-size-${key}.json`);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
currentReports.add(output);
console.log(
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
);
}
function artifactKind(name) {
if (name.endsWith('-mac.zip')) return 'mac.zip';
if (name.endsWith('-win.zip')) return 'win.zip';
const extension = path.extname(name).slice(1);
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
}
function compareSizes(report, previous) {
assert.equal(previous.platform, report.platform);
assert.equal(previous.arch, report.arch);
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
return {
version: previous.version,
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
artifacts: report.artifacts.flatMap((artifact) => {
const old = previousArtifacts.find(
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
);
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
}),
};
}
// Runs after signing and installer creation, before release upload.
async function afterAllArtifactBuild(result) {
const reports = [];
for (const reportPath of currentReports) {
const filename = path.basename(reportPath);
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const key = `${report.platform}-${report.arch}`;
const files = listFiles(path.join(result.outDir, report.appDirectory));
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
report.artifacts = result.artifactPaths.flatMap((file) => {
const kind = artifactKind(file);
if (!kind) return [];
const bytes = fs.statSync(file).size;
return [{ name: path.basename(file), kind, bytes }];
});
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
if (fs.existsSync(previousPath)) {
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
report.comparison = compareSizes(report, previous);
}
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
const summary = [
`### Package size: ${key}`,
'',
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
report.comparison
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
: 'No previous size report available.',
'',
].join('\n');
console.log(summary);
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
reports.push(reportPath);
}
assert(reports.length > 0, 'No package size reports generated by afterPack');
return reports;
verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
console.log(`Package contents verified: ${key}`);
}
module.exports = {
@@ -241,6 +163,4 @@ module.exports = {
verifyAppPath,
listFiles,
listAppFiles,
compareSizes,
default: afterAllArtifactBuild,
};
+4 -30
View File
@@ -6,7 +6,7 @@ import test from 'node:test';
import { createPackageFromStreams } from '@electron/asar';
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
import config from '../package.json';
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
import { listAppFiles, listFiles, verifyAppPath } from './package-audit.cjs';
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
const root = process.cwd();
@@ -125,7 +125,7 @@ test('content audit rejects development files beneath approved roots', () => {
}
});
test('archive inventory handles native files without counting them twice on disk', async () => {
test('content inventory includes packed and unpacked native files', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
try {
const input = path.join(root, 'input');
@@ -147,35 +147,9 @@ test('archive inventory handles native files without counting them twice on disk
streamGenerator: () => createReadStream(path.join(input, name)),
})),
);
assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 },
{ path: 'dist/ai/client.js', bytes: 6 },
]);
assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
statSync(archive).size + 6,
);
assert.deepEqual(listAppFiles(archive), ['main.js', 'native.node', 'dist/ai/client.js']);
assert.deepEqual(listFiles(output).sort(), ['app.asar', 'app.asar.unpacked/native.node']);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('size comparison tolerates older reports without artifact measurements', () => {
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
assert.deepEqual(compareSizes(current, previous), {
version: '0.19.6',
unpackedDeltaBytes: -20,
artifacts: [],
});
assert.deepEqual(
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
.artifacts,
[{ kind: 'AppImage', deltaBytes: -10 }],
);
assert.throws(
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
/Invalid previous size report/,
);
});
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachi-settings-'));
const env = { ...process.env, SUBMINER_DICTIONARY_SMOKE_DATA: profile };
delete env.ELECTRON_RUN_AS_NODE;
try {
const result = spawnSync(
require('electron'),
[
'--no-sandbox',
fileURLToPath(new URL('./check-dictionary-backends.cjs', import.meta.url)),
...process.argv.slice(2),
],
{ env, stdio: 'inherit', timeout: 75_000 },
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
}
+45 -49
View File
@@ -22,39 +22,6 @@ const timeout = setTimeout(() => {
app.exit(1);
}, 60_000);
const STATIC_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.json': 'application/json',
};
// The stats dashboard is served by the stats HTTP server in the app, so load it
// over loopback HTTP from the packaged stats/dist and treat missing static
// assets as failures. API routes are not part of this smoke and may 404.
function serveStatsDist(root, failedRequests) {
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
try {
const body = fs.readFileSync(path.join(root, relative));
res.writeHead(200, {
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
});
res.end(body);
} catch {
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
res.writeHead(404).end();
}
});
server.listen(0, '127.0.0.1');
return server;
}
async function smoke() {
await app.whenReady();
const packagedRequire = createRequire(path.join(archive, 'package.json'));
@@ -83,6 +50,10 @@ async function smoke() {
{ allowFileAccess: true },
);
assert(extension.id, 'Yomitan extension failed to load');
const hachidori = await session
.fromPartition('persist:hachidori')
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
assert(hachidori.id, 'Hachidori extension failed to load');
const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred(
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
@@ -93,9 +64,7 @@ async function smoke() {
failedRequests.push(`${details.url}: ${details.error}`);
},
);
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
await once(statsServer, 'listening');
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
for (const ui of ['renderer', 'settings', 'syncui']) {
const win = new BrowserWindow({
show: false,
webPreferences: {
@@ -104,25 +73,52 @@ async function smoke() {
},
});
try {
if (ui === 'stats') {
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
// Let in-flight font requests settle before the window goes away.
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
} else {
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
assert(loaded, `${ui}: shared Japanese font failed to load`);
}
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
);
assert(loaded, `${ui}: shared Japanese font failed to load`);
} finally {
win.destroy();
}
}
statsServer.close();
// The stats dashboard uses HTTP for both assets and API requests in the app.
const { ImmersionTrackerService } = packagedRequire(
'./dist/core/services/immersion-tracker-service.js',
);
const { createStatsApp, startNodeHttpServer } = packagedRequire(
'./dist/core/services/stats-server.js',
);
const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') });
const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker };
let statsHttp;
const statsServer = await startNodeHttpServer(
createStatsApp(tracker, statsConfig),
statsConfig,
(listener) => (statsHttp = http.createServer(listener)),
);
const statsWindow = new BrowserWindow({ show: false });
try {
const url = `http://127.0.0.1:${statsHttp.address().port}`;
session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => {
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
});
await statsWindow.loadURL(url);
// Let in-flight font requests settle before the window goes away.
await statsWindow.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
for (const endpoint of ['overview', 'sessions']) {
const response = await fetch(`${url}/api/stats/${endpoint}`);
assert.equal(response.status, 200, `Stats ${endpoint} request failed`);
await response.json();
}
} finally {
statsWindow.destroy();
await statsServer.close();
tracker.destroy();
}
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
);
}
+3
View File
@@ -1807,6 +1807,9 @@ export class AnkiIntegration {
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
): Promise<MediaTimingReviewDecision> {
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
log.debug(
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
);
return { action: 'use-original' };
}
return await this.mediaTimingReviewCallback({
@@ -441,6 +441,127 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
}
});
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
const received: unknown[] = [];
let upstreamError: string | null = null;
const upstream = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
received.push(JSON.parse(Buffer.concat(chunks).toString()));
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ result: null, error: upstreamError }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const address = upstream.address();
assert.ok(address && typeof address === 'object');
const processed: number[] = [];
const added: number[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (id) => {
processed.push(id);
},
recordCardsAdded: (count) => {
added.push(count);
},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
});
try {
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
await proxy.waitUntilReady();
const server: unknown = Reflect.get(proxy, 'server');
assert.ok(server instanceof http.Server);
const bound = server.address();
assert.ok(bound && typeof bound === 'object');
for (const [id, marked, error] of [
[51, true, null],
[52, false, null],
[53, true, 'failed'],
] satisfies Array<[number, boolean, string | null]>) {
upstreamError = error;
await fetch(`http://127.0.0.1:${bound.port}`, {
method: 'POST',
body: JSON.stringify({
action: 'updateNoteFields',
version: 6,
params: {
note: { id, fields: { Expression: '猫' } },
...(marked ? { subminerEnrich: true } : {}),
},
}),
});
}
await waitForCondition(() => processed.length > 0);
assert.deepEqual(processed, [51]);
assert.deepEqual(added, []);
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
} finally {
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('stats-owned notes bypass overlay enrichment while popup notes still enqueue', async () => {
const processed: number[] = [];
const added: number[] = [];
const received: unknown[] = [];
let noteId = 70;
const upstream = http.createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
received.push(JSON.parse(Buffer.concat(chunks).toString()));
res.end(JSON.stringify({ result: ++noteId, error: null }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const address = upstream.address();
assert.ok(address && typeof address === 'object');
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (id) => {
processed.push(id);
},
recordCardsAdded: (_count, ids) => {
added.push(...ids);
},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
});
try {
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
await proxy.waitUntilReady();
const server: unknown = Reflect.get(proxy, 'server');
assert.ok(server instanceof http.Server);
const bound = server.address();
assert.ok(bound && typeof bound === 'object');
for (const metadata of [{ subminerEnrich: false }, {}]) {
const response: Response = await fetch(`http://127.0.0.1:${bound.port}`, {
method: 'POST',
body: JSON.stringify({
action: 'addNote',
version: 6,
params: { note: { fields: { Expression: '猫' } }, ...metadata },
}),
});
assert.equal(response.status, 200);
await response.json();
}
await waitForCondition(() => processed.includes(72));
assert.deepEqual(processed, [72]);
assert.deepEqual(added, [71, 72]);
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
} finally {
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('proxy returns addNote response without waiting for background enrichment', async () => {
const processed: number[] = [];
let releaseProcessing: (() => void) | undefined;
+47 -6
View File
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action === 'updateNoteFields') {
const params = requestJson.params;
if (
!params ||
typeof params !== 'object' ||
!('subminerEnrich' in params) ||
params.subminerEnrich !== true
)
return;
const note = 'note' in params ? params.note : null;
if (!note || typeof note !== 'object' || !('id' in note)) return;
const response = this.tryParseJsonValue(responseBody);
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
if (
!response ||
typeof response !== 'object' ||
!('error' in response) ||
response.error !== null ||
!('result' in response) ||
response.result !== null
)
return;
this.enqueueNotes(this.collectSingleResultId(note.id), false);
return;
}
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
return;
}
@@ -239,12 +264,23 @@ export class AnkiConnectProxyServer {
return;
}
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
const noteIds =
action === 'multi'
? this.collectMultiResultIds(requestJson, responseResult)
: this.collectNoteIdsForAction(action, responseResult);
const params = requestJson.params;
if (
action === 'addNote' &&
params &&
typeof params === 'object' &&
'subminerEnrich' in params &&
params.subminerEnrich === false
) {
// Stats owns the saved sentence and media; the live mpv context is unrelated.
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
return;
}
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
void this.enqueueMostRecentAddedNote();
return;
@@ -293,7 +329,7 @@ export class AnkiConnectProxyServer {
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote') {
if (action !== 'addNote' && action !== 'updateNoteFields') {
return requestJson;
}
@@ -301,12 +337,17 @@ export class AnkiConnectProxyServer {
requestJson.params && typeof requestJson.params === 'object'
? (requestJson.params as Record<string, unknown>)
: null;
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
if (
!params ||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
) {
return requestJson;
}
const nextParams = { ...params };
delete nextParams.subminerDuplicateNoteIds;
delete nextParams.subminerEnrich;
return {
...requestJson,
params: nextParams,
@@ -455,7 +496,7 @@ export class AnkiConnectProxyServer {
});
}
private enqueueNotes(noteIds: number[]): void {
private enqueueNotes(noteIds: number[], recordAdded = true): void {
let enqueuedCount = 0;
const acceptedIds: number[] = [];
for (const noteId of noteIds) {
@@ -472,7 +513,7 @@ export class AnkiConnectProxyServer {
return;
}
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
this.processQueue();
}
@@ -8,6 +8,7 @@ import {
import type { SubtitleMiningContext } from '../types/subtitle';
import type { CardKind } from '../types/anki';
import { applyCardKindFlagFields } from './card-kinds';
import { STATS_MINING_TAG } from '../shared/anki-source';
function setCardTypeFields(
updatedFields: Record<string, string>,
@@ -110,6 +111,27 @@ function createWorkflowHarness() {
};
}
test('NoteUpdateWorkflow preserves stats cards discovered by polling', async () => {
const { workflow, deps, updates } = createWorkflowHarness();
const note = {
noteId: 42,
tags: [STATS_MINING_TAG],
fields: { Expression: { value: '猫' }, Sentence: { value: '猫がいる。' } },
};
deps.client.notesInfo = async () => [note];
deps.captureSubtitleMediaContext = () => assert.fail('Must not capture current playback');
deps.findDuplicateNote = async () => assert.fail('Must not regroup a stats card');
let cachedNote: NoteUpdateWorkflowNoteInfo | undefined;
deps.appendKnownWordsFromNoteInfo = (value) => {
cachedNote = value;
};
await workflow.execute(42);
assert.deepEqual(updates, []);
assert.equal(cachedNote, note);
});
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
const harness = createWorkflowHarness();
@@ -8,9 +8,11 @@ import type {
WordCardKind,
} from '../types/anki';
import { resolveWordCardKind } from './note-field-utils';
import { STATS_MINING_TAG } from '../shared/anki-source';
export interface NoteUpdateWorkflowNoteInfo {
noteId: number;
tags?: string[];
fields: Record<string, { value: string }>;
}
@@ -185,6 +187,10 @@ export class NoteUpdateWorkflow {
}
const noteInfo = notesInfo[0]!;
if (noteInfo.tags?.includes(STATS_MINING_TAG)) {
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
return;
}
const fields = this.deps.extractFields(noteInfo.fields);
const config = this.deps.getConfig();
+19
View File
@@ -8,6 +8,7 @@ import {
isStandaloneTexthookerCommand,
parseArgs,
shouldRunYomitanOnlyStartup,
shouldRunDictionarySettingsOnlyStartup,
shouldStartApp,
} from './args';
@@ -475,3 +476,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(setup), true);
assert.equal(shouldStartApp(setup), true);
});
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
const args = parseArgs(['--hachidori']);
assert.equal(args.hachidori, true);
assert.equal(args.yomitan, false);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
assert.equal(shouldRunYomitanOnlyStartup(args), false);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
assert.equal(
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
false,
);
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
});
+11 -1
View File
@@ -13,6 +13,7 @@ export interface CliArgs {
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
yomitan: boolean;
hachidori: boolean;
settings: boolean;
syncWindow: boolean;
setup: boolean;
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -285,6 +287,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--yomitan') args.yomitan = true;
else if (arg === '--hachidori') args.hachidori = true;
else if (arg === '--settings') args.settings = true;
else if (arg === '--sync-window') args.syncWindow = true;
else if (arg === '--setup') args.setup = true;
@@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.yomitan &&
!args.hachidori &&
!args.settings &&
!args.syncWindow &&
!args.setup &&
@@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.syncWindow ||
args.setup ||
@@ -769,8 +775,12 @@ export function shouldStartApp(args: CliArgs): boolean {
}
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
}
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
return (
args.yomitan &&
(args.yomitan || args.hachidori) &&
!args.background &&
!args.start &&
!args.stop &&
+1
View File
@@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--settings\s+Open SubMiner settings window/);
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
+1
View File
@@ -25,6 +25,7 @@ ${B}Overlay${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--yomitan Open Yomitan settings window
--hachidori Open Hachidori settings window
--settings Open SubMiner settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+4
View File
@@ -22,6 +22,8 @@ export type {
} from './definitions/shared';
const {
dictionaryBackend,
hachidori,
subtitlePosition,
keybindings,
websocket,
@@ -58,6 +60,8 @@ const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
dictionaryBackend,
hachidori,
subtitlePosition,
keybindings,
websocket,
+4
View File
@@ -2,6 +2,8 @@ import { ResolvedConfig } from '../../types/config';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'dictionaryBackend'
| 'hachidori'
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
@@ -18,6 +20,8 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'notifications'
| 'auto_start_overlay'
> = {
dictionaryBackend: 'yomitan',
hachidori: { externalHostManagementUrl: '' },
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
+14
View File
@@ -81,6 +81,20 @@ export function buildCoreConfigOptionRegistry(
] as const;
return [
{
path: 'hachidori.externalHostManagementUrl',
kind: 'string',
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
description:
'Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.',
},
{
path: 'dictionaryBackend',
kind: 'enum',
enumValues: ['yomitan', 'hachidori'],
defaultValue: defaultConfig.dictionaryBackend,
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
},
{
path: 'logging.level',
kind: 'enum',
@@ -1,6 +1,22 @@
import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Dictionary Backend',
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
notes: [
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
],
key: 'dictionaryBackend',
},
{
title: 'Hachidori External Dictionary Imports',
description: [
'Configure the linked Docker host management URL, for example http://127.0.0.1:8780.',
],
notes: ['Used only while Hachidori is linked to an external host.'],
key: 'hachidori',
},
{
title: 'Subtitle Selection',
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
+27
View File
@@ -2,10 +2,37 @@ import { ResolveContext } from './context';
import { applyControllerConfig } from './controller';
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
import { asBoolean, asNumber, asString, isObject } from './shared';
import { parseHachidoriManagementUrl } from '../../shared/hachidori-sharing';
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (isObject(src.hachidori) && src.hachidori.externalHostManagementUrl !== undefined) {
try {
resolved.hachidori.externalHostManagementUrl = parseHachidoriManagementUrl(
src.hachidori.externalHostManagementUrl,
);
} catch {
warn(
'hachidori.externalHostManagementUrl',
src.hachidori.externalHostManagementUrl,
resolved.hachidori.externalHostManagementUrl,
'Expected an HTTP(S) origin or an empty string.',
);
}
}
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
resolved.dictionaryBackend = src.dictionaryBackend;
} else if (src.dictionaryBackend !== undefined) {
warn(
'dictionaryBackend',
src.dictionaryBackend,
resolved.dictionaryBackend,
"Expected 'yomitan' or 'hachidori'.",
);
}
if (isObject(src.subtitleSelection)) {
const enabled = asBoolean(src.subtitleSelection.enabled);
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
import { buildConfigSettingsRegistry } from '../settings/registry';
import { createResolveContext } from './context';
import { applyCoreDomainConfig } from './core-domains';
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
assert.equal(resolved.dictionaryBackend, 'hachidori');
assert.deepEqual(warnings, []);
const field = buildConfigSettingsRegistry(resolved).find(
(entry) => entry.configPath === 'dictionaryBackend',
);
assert.equal(field?.restartBehavior, 'restart');
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
assert.equal(field?.category, 'integrations');
});
test('unknown dictionary backend values warn and preserve the default', () => {
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
const { context, warnings } = createResolveContext({});
context.src.dictionaryBackend = dictionaryBackend;
applyCoreDomainConfig(context);
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
assert.equal(warnings.length, 1);
assert.equal(warnings[0]?.path, 'dictionaryBackend');
}
});
test('Hachidori external import URL accepts HTTP origins and rejects invalid targets', () => {
assert.equal(resolveConfig({}).resolved.hachidori.externalHostManagementUrl, '');
const result = resolveConfig({
hachidori: { externalHostManagementUrl: 'http://127.0.0.1:8780/' },
});
assert.equal(result.resolved.hachidori.externalHostManagementUrl, 'http://127.0.0.1:8780');
assert.deepEqual(result.warnings, []);
for (const value of [
'file:///tmp/dict',
'http://host/import',
'http://user:password@host',
true,
]) {
const { context, warnings } = createResolveContext({});
context.src.hachidori = { externalHostManagementUrl: value };
applyCoreDomainConfig(context);
assert.equal(context.resolved.hachidori.externalHostManagementUrl, '');
assert.equal(warnings[0]?.path, 'hachidori.externalHostManagementUrl');
}
});
+7 -3
View File
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
'Discord Rich Presence',
'Jellyfin',
'Texthooker',
'Dictionary Lookup',
'Yomitan',
'Stats dashboard',
'Startup warmups',
@@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
'subtitleStyle.css': 'CSS Declarations',
@@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'subtitleSidebar.css':
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
'subtitleStyle.primaryVisibleOnYomitanPopup':
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
'websocket.enabled':
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
@@ -340,6 +341,9 @@ function humanizePath(path: string): string {
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (path === 'dictionaryBackend' || path.startsWith('hachidori.')) {
return { category: 'integrations', section: 'Dictionary Lookup' };
}
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
@@ -2525,6 +2525,8 @@ Aligned English subtitle
await new Promise((resolve) => setTimeout(resolve, 1));
}
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
const addRequest = requests.find((request) => request.action === 'addNote');
assert.deepEqual(addRequest?.params?.note?.tags, ['SubMiner', 'SubMiner::Stats']);
mediaRelease.audio?.();
mediaRelease.image?.();
@@ -6,6 +6,7 @@ import test from 'node:test';
import { createCoverArtFetcher, stripFilenameTags } from './cover-art-fetcher.js';
import { Database } from '../immersion-tracker/sqlite.js';
import {
applyPragmas,
ensureSchema,
getOrCreateAnimeRecord,
getOrCreateVideoRecord,
@@ -50,6 +51,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-test.mkv', {
canonicalTitle: 'Cover Fetcher Test',
@@ -108,6 +110,7 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-cache-1.mkv', {
canonicalTitle: 'Shared Cover Show',
@@ -191,6 +194,7 @@ function createJsonResponse(payload: unknown): Response {
test('fetchIfMissing uses guessit primary title and season when available', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-season-test.mkv', {
canonicalTitle:
@@ -296,6 +300,7 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
test('fetchIfMissing falls back to internal parser when guessit throws', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-fallback-test.mkv', {
canonicalTitle: 'School Vlog S01E01',
@@ -360,6 +365,7 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
@@ -438,6 +444,7 @@ test('fetchIfMissing caches a no-match when the season cannot be resolved', asyn
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
@@ -545,6 +552,7 @@ for (const linkedToAnilist of [false, true]) {
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
@@ -662,6 +670,7 @@ for (const linkedToAnilist of [false, true]) {
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
applyPragmas(db);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
+1
View File
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
+16
View File
@@ -20,6 +20,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -133,6 +134,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
togglePrimarySubtitleBar: () => {
calls.push('togglePrimarySubtitleBar');
},
openHachidoriSettingsDelayed: (delayMs) => {
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
},
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
@@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
},
ui: {
openFirstRunSetup: () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -1133,3 +1138,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
assert.ok(calls.includes('stopApp'));
});
for (const source of ['initial', 'second-instance'] as const) {
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
assert.equal(calls.includes('initializeOverlayRuntime'), false);
assert.equal(calls.includes('connectMpvClient'), false);
});
}
+9
View File
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
openHachidoriSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
@@ -170,6 +171,7 @@ interface MiningCliRuntime {
interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime(
options.ui.openYomitanSettings();
}, delayMs);
},
openHachidoriSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openHachidoriSettings();
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
openSyncUiWindow: options.ui.openSyncUiWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
@@ -425,6 +432,8 @@ export function handleCliCommand(
deps.logDebug('Opened first-run setup flow.');
} else if (args.yomitan) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.hachidori) {
deps.openHachidoriSettingsDelayed(1000);
} else if (args.settings) {
deps.openConfigSettingsWindow();
} else if (args.syncWindow) {
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveHachidoriExtensionPath } from './hachidori-extension';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
assert.equal(
resolveHachidoriExtensionPath({
...options,
exists: (p) => p === '/app/build/hachidori/manifest.json',
}),
'/app/build/hachidori',
);
assert.equal(
resolveHachidoriExtensionPath({
...options,
exists: (p) => p === '/resources/hachidori/manifest.json',
}),
'/resources/hachidori',
);
assert.throws(
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
/build:hachidori/,
);
});
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
try {
const source = path.join(root, 'source');
const profile = path.join(root, 'profile');
fs.mkdirSync(source);
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
typeof ensureExtensionCopyAsync
>[2];
const copy = await ensureExtensionCopyAsync(source, profile, options);
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
assert.equal(
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
'preserved',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+59
View File
@@ -0,0 +1,59 @@
import electron from 'electron';
import type { Extension, Session } from 'electron';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
export function getHachidoriSession(): Session {
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
}
export function resolveHachidoriExtensionPath(options: {
moduleDir: string;
resourcesPath: string;
exists?: (candidate: string) => boolean;
}): string {
const candidates = [
path.resolve(options.moduleDir, '../../../build/hachidori'),
path.join(options.resourcesPath, 'hachidori'),
];
const found = candidates.find((candidate) =>
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
);
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
return found;
}
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
export function createHachidoriExtensionRuntime(userDataPath: string) {
let extension: Extension | null = null;
let loading: Promise<Extension> | null = null;
return {
getSession: getHachidoriSession,
ensureLoaded(): Promise<Extension> {
if (extension) return Promise.resolve(extension);
if (loading) return loading;
loading = (async () => {
const source = resolveHachidoriExtensionPath({
moduleDir: __dirname,
resourcesPath: process.resourcesPath,
});
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
extensionName: 'hachidori',
});
const session = getHachidoriSession();
// Electron can reuse an old extension worker after its files change.
// Drop worker registrations before loading, preserving dictionaries and settings.
await session.clearStorageData({ storages: ['serviceworkers'] });
extension = await session.extensions.loadExtension(copy.targetDir, {
allowFileAccess: true,
});
return extension;
})().finally(() => {
loading = null;
});
return loading;
},
};
}
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -368,7 +369,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
}
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const tags = ankiConfig.tags ?? ['SubMiner'];
const tags = [...new Set([...(ankiConfig.tags ?? ['SubMiner']), STATS_MINING_TAG])];
const addNotePromise = timeMiningPhase(
mode,
+20
View File
@@ -4362,6 +4362,26 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds'
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
});
test('tokenizeSubtitle skips frequency requests for ranks supplied by the scanner', async () => {
const deps = makeDepsFromYomitanTokens(
[{ surface: '猫', reading: 'ねこ', headword: '猫', frequencyRank: 42 }],
{ getFrequencyDictionaryEnabled: () => true },
);
const parserWindow = deps.getYomitanParserWindow();
assert.ok(parserWindow);
deps.getYomitanParserWindow = () => parserWindow;
const scripts: string[] = [];
const execute = parserWindow.webContents.executeJavaScript.bind(parserWindow.webContents);
parserWindow.webContents.executeJavaScript = async (script) => {
scripts.push(script);
return execute(script);
};
const result = await tokenizeSubtitle('猫', deps);
assert.equal(result.tokens?.[0]?.frequencyRank, 42);
assert.ok(scripts.length > 0);
assert.equal(scripts.filter((script) => script.includes('getTermFrequencies')).length, 0);
});
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
const result = await tokenizeSubtitle(
'第二走者',
+1
View File
@@ -494,6 +494,7 @@ function buildYomitanFrequencyTermReadingList(
): Array<{ term: string; reading: string | null }> {
const termReadingList: Array<{ term: string; reading: string | null }> = [];
for (const token of tokens) {
if (normalizePositiveFrequencyRank(token.frequencyRank) !== null) continue;
const readingRaw =
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import vm from 'node:vm';
import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const config = {
tags: ['SubMiner', 'Japanese'],
fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' },
};
const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
async function harness(
anki: unknown = {},
models: Record<string, string[]> = { Japanese: modelFields },
) {
const templates: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href
);
const setup: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href
);
const context = vm.createContext({
__templates: templates,
__setup: setup,
__initialAnki: anki,
__models: models,
structuredClone,
URL,
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
vm.runInContext(
`
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
let writes = 0, online = true, race = false, proxy = null, failWrites = false;
const readOptions = async () => structuredClone(options);
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
const send = async (type, request, target) => {
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
if (failWrites) throw Error('offline');
if (race) {
race = false;
options.anki.templates[0].tags = options.anki.tags = ['User edit'];
options.revision++;
}
if (request.baseRevision !== options.revision) throw Error('conflict');
options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 };
writes++;
};
const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({
connected: online, model, models: Object.keys(__models), decks: ['Mining'],
fields: __models[model] || [], errors: online ? [] : ['offline'],
}) }) };
`,
context,
);
await vm.runInContext(
HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates')
.replace("await import('./anki-setup.js')", '__setup')
.replace("await import('./anki.js')", '__gateway'),
context,
);
const run = async (script: string): Promise<unknown> =>
structuredClone(await vm.runInContext(script, context));
const sync = () =>
run(
`__subminerSyncAnkiSettings(${JSON.stringify({
server: 'http://127.0.0.1:8766',
deck: 'Mining',
forceOverride: true,
hints: buildHachidoriAnkiHints(config),
})})`,
);
return { run, sync };
}
test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => {
const h = await harness();
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
assert.deepEqual(
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
);
assert.deepEqual(
await h.run(
'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))',
),
{
Term: '{expression}',
Reading: '{reading}',
Definition: '{definition}',
Context: '{sentence}',
Pronunciation: '{audio}',
Image: '{screenshot}',
},
);
await h.sync();
assert.equal(await h.run('writes'), 1);
});
// SubMiner's new-card polling only watches ankiConnect.deck, so the first
// template's deck follows it the way Yomitan's term card deck does.
test('moves the first template to the SubMiner deck and preserves the rest of custom templates', async () => {
const h = await harness({
templates: [
{
id: 'default',
name: 'Custom',
deck: 'Own deck',
model: 'Japanese',
tags: ['own'],
fieldTemplates: {
Term: { value: '{reading}', overwriteMode: 'overwrite' },
Context: { value: '', overwriteMode: 'coalesce' },
},
},
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
],
});
const before = (await h.run('options.anki.templates')) as Array<Record<string, unknown>>;
await h.sync();
assert.deepEqual(await h.run('options.anki.templates'), [
{ ...before[0], deck: 'Mining' },
...before.slice(1),
]);
assert.equal(await h.run('options.anki.deck'), 'Mining');
});
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
const h = await harness({}, { Japanese: modelFields, Second: modelFields });
await h.run('online = false');
assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true });
assert.equal(await h.run('options.anki.deck'), 'Mining');
await h.run('online = true');
await h.sync();
assert.equal(await h.run('options.anki.model'), '');
await h.run('delete __models.Second');
await h.sync();
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('fills missing basic mappings only with fields belonging to the selected model', async () => {
const h = await harness(
{ model: 'Japanese', fields: { expression: 'Reading' } },
{ Japanese: ['Term', 'Reading', 'Context'] },
);
await h.sync();
assert.deepEqual(
await h.run(
'[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]',
),
['Reading', 'Context', '', null],
);
});
test('re-reads concurrent settings edits before retrying its revisioned write', async () => {
const h = await harness();
await h.run('race = true');
await h.sync();
assert.deepEqual(await h.run('options.anki.tags'), ['User edit']);
assert.equal(await h.run('options.anki.model'), 'Japanese');
});
test('restores the previous proxy marker when every settings write fails', async () => {
const h = await harness();
await h.run(`proxy = 'http://127.0.0.1:9000'; failWrites = true`);
await assert.rejects(h.sync(), /offline/);
assert.equal(await h.run('proxy'), 'http://127.0.0.1:9000');
});
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
const hints = buildHachidoriAnkiHints({
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
});
assert.equal(hints.fields.audio, 'WordAudio');
assert.equal('captureAudio' in hints.fields, false);
});
@@ -0,0 +1,124 @@
import type { AnkiConnectConfig } from '../../../types';
// Only settings with the same meaning in both apps cross this boundary.
export function buildHachidoriAnkiHints(config: AnkiConnectConfig) {
return {
tags: config.tags,
fields: {
expression: config.fields?.word?.trim(),
audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(),
sentence: config.fields?.sentence?.trim(),
screenshot: config.fields?.image?.trim(),
},
model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined,
family: config.isLapis?.enabled
? 'lapis'
: config.isKiku?.enabled
? 'kiku'
: config.isSenren?.enabled
? 'senren'
: undefined,
};
}
// Installed inside the hidden Hachidori settings page. Uses local option writes
// even when dictionaries are remote or their host is offline.
export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => {
const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null);
const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js');
const { ankiSetupFamily } = await import('./anki-setup.js');
const { createAnkiGateway } = await import('./anki.js');
const gateway = createAnkiGateway({ timeoutMs: 2000 });
const discoveries = new Map();
const discover = async (anki, model) => {
const key = JSON.stringify([anki.url, anki.apiKey, model]);
if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model }));
return discoveries.get(key);
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const options = await readOptions();
let anki = options.anki;
const canReplaceServer = forceOverride || !anki.url || anki.url === server
|| anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy;
if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' };
anki = { ...anki, url: server };
const first = anki.templates[0];
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
let template = { ...first, fields: { ...first.fields } };
// SubMiner's new-card polling only watches its configured deck, so the
// first template follows it like Yomitan's term card deck.
if (deck) template.deck = deck;
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
template.tags = [...hints.tags];
}
let pending = false;
// Advanced templates include intentionally blank fields. Preserve them
// as a unit rather than replacing them with inferred mappings.
if (hints && first.fieldTemplates === null) {
let discovery = await discover(anki, template.model || hints.model || '');
if (!discovery.connected) {
pending = true;
} else {
if (!template.model) {
const candidates = hints.model
? discovery.models.filter(model => model === hints.model)
: discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family);
const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean);
const matches = [];
// Require a configured word and sentence field for an inferred
// model. Never choose the first of several compatible note types.
if (anchors.length === 2) for (const model of candidates) {
const result = await discover(anki, model);
if (!result.connected || result.errors.length) { pending = true; break; }
if (anchors.every(field => result.fields.includes(field))) matches.push(result);
}
if (!pending && matches.length === 1) {
discovery = matches[0];
template.model = discovery.model;
}
}
if (template.model && discovery.model === template.model && discovery.fields.length) {
const fields = discovery.fields;
for (const [semantic, field] of Object.entries(hints.fields)) {
if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field;
}
if (Object.values(first.fields).every(value => !value)) {
const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic');
const configured = resolveAnkiTemplates(template, fields).templates;
const markers = new Set(Object.entries(template.fields)
.filter(([, value]) => value).map(([key]) => '{' + key + '}'));
for (const row of Object.values(preset.fieldTemplates)) {
for (const marker of markers) row.value = row.value.replaceAll(marker, '');
}
for (const [field, row] of Object.entries(configured)) {
if (row.value) preset.fieldTemplates[field] = row;
}
template = preset;
}
}
}
}
// Hachidori retains a compatibility projection of its first template.
// Updating both prevents its normalizer from restoring stale values.
const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS
.map(key => [key, template[key]]));
anki = { ...anki, ...templateConfig,
templates: [template, ...anki.templates.slice(1)] };
const changed = JSON.stringify(anki) !== JSON.stringify(options.anki);
try {
if (changed) await send('hd_options_write', {
baseRevision: options.revision, options: { anki },
}, 'hoshidicts-worker');
return { updated: changed, matched: !pending, pending };
} catch (error) {
if (attempt > 0) {
// The server was never written, so the marker must not claim it.
await globalThis.__subminerSetAnkiProxyUrl(previousProxy);
throw error;
}
// Re-read after a concurrent settings save before filling anything.
}
}
};
`;
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { once } from 'node:events';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
test('linked Hachidori uploads replacement bytes, retries a busy host, and never invokes local import', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
const archive = Buffer.from('PK-test-archive');
await writeFile(zipPath, archive);
let attempts = 0;
const server = createServer(async (request, response) => {
attempts += 1;
assert.equal(request.url, '/import?name=merged.zip&replace=true');
assert.equal(request.method, 'POST');
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
assert.deepEqual(Buffer.concat(chunks), archive);
response.writeHead(attempts === 1 ? 409 : 200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify(attempts === 1 ? { error: 'busy' } : { ok: true, report: { success: true } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
const managementUrl = `http://127.0.0.1:${address.port}`;
let connected = true;
const deps = {
...createDeps(async (script) => {
assert.ok(script.includes('hd_sharing_status'), 'must not invoke local ZIP automation');
return {
ok: true,
sharing: {
client: {
linked: true,
connected,
address: 'ws://127.0.0.1:8771/link',
host: { dictionaryCount: 8 },
},
},
};
}),
getYomitanExt: () => ({
id: 'hachi',
name: 'Hachidori',
version: '1',
path: '',
url: '',
manifest: {},
}),
};
try {
assert.equal(
await importYomitanDictionaryFromZip(zipPath, deps, { error: assert.fail }, managementUrl),
true,
);
assert.equal(attempts, 2);
const errors: string[] = [];
const logger = { error: (...args: unknown[]) => errors.push(args.join(' ')) };
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger), false);
assert.match(errors.pop() ?? '', /externalHostManagementUrl/);
connected = false;
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false);
assert.equal(attempts, 2);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
test('Hachidori upload requires a successful import report, not just HTTP success', async () => {
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
await writeFile(zipPath, 'bad archive');
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({ ok: true, report: { success: false, error: 'Invalid archive' } }),
);
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
assert.ok(address && typeof address === 'object');
try {
await assert.rejects(
uploadHachidoriDictionary(zipPath, `http://127.0.0.1:${address.port}`),
/Invalid archive/,
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
@@ -0,0 +1,59 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { parseHachidoriManagementUrl } from '../../../shared/hachidori-sharing';
// Upload from the main process: extension blob URLs cannot cross the sharing link,
// and the Docker management API deliberately rejects browser cross-origin writes.
export async function uploadHachidoriDictionary(
zipPath: string,
managementUrl: string,
): Promise<void> {
const origin = parseHachidoriManagementUrl(managementUrl);
if (!origin) {
throw new Error(
'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.',
);
}
const url = new URL('/import', origin);
url.searchParams.set('name', path.basename(zipPath));
url.searchParams.set('replace', 'true');
const bytes = await readFile(zipPath);
const signal = AbortSignal.timeout(300_000);
for (;;) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: bytes,
signal,
redirect: 'error',
});
if (response.status === 409) {
await response.body?.cancel();
await delay(500, undefined, { signal });
continue;
}
const result: unknown = await response.json();
if (
response.ok &&
typeof result === 'object' &&
result !== null &&
'ok' in result &&
result.ok === true &&
'report' in result &&
typeof result.report === 'object' &&
result.report !== null &&
'success' in result.report &&
result.report.success === true
)
return;
const detail =
typeof result === 'object' &&
result !== null &&
'error' in result &&
typeof result.error === 'string'
? result.error
: JSON.stringify(result);
throw new Error(`Hachidori dictionary upload failed (${response.status}): ${detail}`);
}
}
@@ -0,0 +1,493 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { pathToFileURL } from 'node:url';
import * as vm from 'node:vm';
import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge';
import {
requestYomitanScanTokens,
requestYomitanParseResults,
requestYomitanTermFrequencies,
syncYomitanDefaultAnkiServer,
addYomitanNoteViaSearch,
} from './yomitan-parser-runtime';
import { createDeps } from './yomitan-scan-test-harness';
import { selectYomitanParseTokens } from './parser-selection-stage';
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
const characterDictionary = 'SubMiner Character Dictionary (AniList 1)';
function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') {
return {
matched,
deinflected: expression,
trace: [],
term: {
expression,
reading,
score: 0,
rules: 'v1',
glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }],
pitches: [],
frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }],
},
};
}
async function createHarness(emptyLibrary = false) {
const messages: Array<Record<string, unknown>> = [];
let dictionaryRevision = 2;
let optionRevision = 3;
let anki = {
url: 'http://127.0.0.1:8765',
templates: [
{ id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} },
{ id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} },
],
};
let dictionaries = [
{ id: 'terms', title: 'JMdict', displayName: 'Main dictionary', enabled: true, revision: '1' },
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
{
id: 'frequency',
title: 'Frequency',
enabled: true,
revision: '1',
frequencyMode: 'rank-based',
frequencyCount: 1,
},
];
let duplicate = false;
let proxyUrl: unknown;
let loadingStatusReplies = 0;
let busyEngineReplies = 0;
const apiModule: unknown = await import(
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
);
const context = vm.createContext({
__testApiModule: apiModule,
setTimeout,
crypto,
URL,
Blob,
Uint8Array,
atob,
chrome: {
storage: {
local: {
set: async (value: Record<string, unknown>) => {
proxyUrl = value.subminerAnkiProxyUrl;
},
get: async () => ({
options: { anki, scanLength: 40, revision: optionRevision },
subminerAnkiProxyUrl: proxyUrl,
}),
},
},
runtime: {
getManifest: () => ({ version: 'test' }),
sendMessage: async (message: Record<string, unknown>) => {
messages.push(structuredClone(message));
if (message.type === 'hd_status') {
const loading = loadingStatusReplies > 0;
if (loading) loadingStatusReplies -= 1;
return { ok: true, ready: !loading, loading };
}
if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') {
busyEngineReplies -= 1;
return { ok: false, error: 'the dictionary engine is busy mutating' };
}
switch (message.type) {
case 'hd_state_read':
return {
ok: true,
state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries },
};
case 'hd_lookup': {
const text = String(message.text);
const candidates = [
lookupResult('食べた', '食べる', 'たべる'),
lookupResult('食べる', '食べる', 'たべる'),
lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary),
];
return {
ok: true,
generation: 1,
results: candidates.filter((result) => text.startsWith(result.matched)),
};
}
case 'hd_options_write': {
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
const update = message.options;
assert.ok(update && typeof update === 'object' && 'anki' in update);
const value = update.anki;
assert.ok(
value &&
typeof value === 'object' &&
'url' in value &&
typeof value.url === 'string',
);
assert.ok('templates' in value && Array.isArray(value.templates));
anki = { url: value.url, templates: value.templates };
optionRevision += 1;
return { ok: true };
}
case 'hd_apply_state': {
assert.equal(message.baseRevision, dictionaryRevision);
assert.ok(Array.isArray(message.dictionaries));
dictionaries = message.dictionaries;
dictionaryRevision += 1;
return { ok: true };
}
case 'hd_anki_status':
return { ok: true, configKey: 'configuration' };
case 'hd_anki_preflight':
return {
ok: true,
canAdd: !duplicate,
state: duplicate ? 'duplicate' : 'addable',
noteIds: duplicate ? [15] : [],
};
case 'hd_anki_submit':
return { ok: true, state: 'added', noteId: 19 };
case 'hd_import':
return { ok: true, report: { success: true } };
case 'hd_remove':
return { ok: true };
default:
throw new Error('Unexpected native request: ' + String(message.type));
}
},
},
},
});
vm.runInContext('globalThis.window = globalThis', context);
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
"await import('./api-host.js')",
'__testApiModule',
).replace("await import('./reader-options.js')", 'Promise.resolve()');
await vm.runInContext(script, context);
const run = async (code: string): Promise<unknown> =>
structuredClone(await vm.runInContext(code, context));
const invoke = (action: string, params?: unknown) =>
run(`new Promise((resolve, reject) => {
__subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => {
if (response.error) reject(new Error(response.error.message)); else resolve(response.result);
});
})`);
return {
deps: createDeps(run),
run,
invoke,
messages,
anki: () => anki,
proxyUrl: () => proxyUrl,
setAnkiServer: (url: string) => {
anki = { ...anki, url };
},
setAnkiTemplates: (templates: typeof anki.templates) => {
anki = { ...anki, templates };
},
dictionaries: () => dictionaries,
disableDictionary: (id: string) => {
dictionaries = dictionaries.map((entry) =>
entry.id === id ? { ...entry, enabled: false } : entry,
);
dictionaryRevision += 1;
},
setDuplicate: () => {
duplicate = true;
},
changeRevision: () => {
optionRevision += 1;
},
setLoadingStatusReplies: (count: number) => {
loadingStatusReplies = count;
},
setBusyEngineReplies: (count: number) => {
busyEngineReplies = count;
},
};
}
test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => {
const harness = await createHarness();
const tokens = await requestYomitanScanTokens(
'ミナト 食べた',
harness.deps,
{ error: assert.fail },
{
includeNameMatchMetadata: true,
currentCharacterDictionaryMediaId: 1,
},
);
assert.ok(tokens);
assert.equal(tokens[0]?.surface, 'ミナト');
assert.equal(tokens[0]?.isNameMatch, true);
assert.equal(tokens[1]?.surface, '食べた');
assert.equal(tokens[1]?.headword, '食べる');
assert.equal(tokens[1]?.startPos, 4);
assert.equal(tokens[1]?.endPos, 7);
assert.equal(tokens[1]?.frequencyRank, 42);
assert.deepEqual(tokens[1]?.wordClasses, ['v1']);
const frequencies = await requestYomitanTermFrequencies(
[{ term: '食べる', reading: 'たべる' }],
harness.deps,
{ error: assert.fail },
);
assert.equal(frequencies[0]?.frequency, 42);
assert.equal(frequencies[0]?.dictionary, 'Frequency');
});
test('Hachidori frequency lookups match API headwords, readings and requested dictionaries', async () => {
const harness = await createHarness();
const query = (term: string, reading: string | null, dictionaries = ['Frequency']) =>
harness.invoke('getTermFrequencies', { termReadingList: [{ term, reading }], dictionaries });
assert.deepEqual(await query('食べる', 'たべる'), [
{
term: '食べる',
reading: 'たべる',
hasReading: false,
dictionary: 'Frequency',
frequency: 42,
displayValue: null,
displayValueParsed: false,
},
]);
assert.deepEqual(await query('食べる', 'べつのよみ'), []);
assert.deepEqual(await query('食べる', null, ['Other frequency']), []);
assert.deepEqual(await query('食べるだけ', null), []);
assert.deepEqual(await query('頻度だけ', null), []);
assert.deepEqual(await query('食べる', null), await query('食べる', 'たべる'));
});
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
const harness = await createHarness();
const synced = await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ deck: 'Mining' },
);
assert.equal(synced, true);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.deepEqual(
harness.anki().templates.map((template) => template.deck),
['Mining', 'Mining'],
);
assert.equal(harness.proxyUrl(), null);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766');
await assert.rejects(
harness.invoke('setAllSettings', {
value: {
hachidoriRevisions: { dictionaries: 99, options: 99 },
profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }],
},
}),
/settings changed/,
);
});
test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => {
const harness = await createHarness();
const projected = (await harness.invoke('optionsGetFull')) as {
profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>;
};
const jmdict = projected.profiles[0]!.options.dictionaries.find(
(entry) => entry.name === 'JMdict',
);
jmdict!.enabled = false;
// Hachidori changed both revisions after SubMiner read them.
harness.disableDictionary('names');
harness.setAnkiServer('http://192.168.1.10:8765');
harness.changeRevision();
assert.equal(await harness.invoke('setAllSettings', { value: projected }), true);
assert.deepEqual(
harness.dictionaries().map((entry) => [entry.id, entry.enabled]),
[
['terms', false],
['names', false],
['frequency', true],
],
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0);
});
test('Hachidori settings writes survive an empty template list', async () => {
const harness = await createHarness();
harness.setAnkiTemplates([]);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{
forceOverride: true,
},
),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
});
test('Hachidori initializes an empty library before the first dictionary import', async () => {
const harness = await createHarness(true);
assert.deepEqual(await harness.invoke('getDictionaryInfo'), []);
assert.equal(
await syncYomitanDefaultAnkiServer(
'http://127.0.0.1:8766',
harness.deps,
{ error: assert.fail },
{ forceOverride: true },
),
true,
);
});
test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
true,
);
assert.equal(harness.anki().url, 'http://127.0.0.1:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => {
const harness = await createHarness();
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
forceOverride: true,
}),
true,
);
harness.setAnkiServer('http://192.168.1.10:8765');
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
false,
);
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
assert.equal(harness.proxyUrl(), null);
});
test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => {
const harness = await createHarness();
const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, {
error: assert.fail,
});
const tokens = selectYomitanParseTokens(parsed, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
reading: token.reading,
start: token.startPos,
})),
[
{ surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 },
{ surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 },
],
);
});
test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => {
const harness = await createHarness();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: 19,
duplicateNoteIds: [],
});
harness.setDuplicate();
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
noteId: null,
duplicateNoteIds: [15],
});
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
});
test('Hachidori mining requests render native dictionary aliases and frequency markers', async () => {
const harness = await createHarness();
await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail });
const request = harness.messages.find((message) => message.type === 'hd_anki_preflight')?.request;
assert.ok(request && typeof request === 'object');
assert.ok('subminerEnrich' in request && request.subminerEnrich === false);
const native: unknown = await import(
pathToFileURL(path.join(extensionPath, 'anki-values.js')).href
);
assert.ok(native && typeof native === 'object' && 'buildAnkiFields' in native);
assert.equal(typeof native.buildAnkiFields, 'function');
if (typeof native.buildAnkiFields !== 'function') assert.fail('Native renderer is unavailable');
const fields: unknown = await native.buildAnkiFields(
request,
{
Dictionary: { value: '{dictionary-alias}' },
Frequency: { value: '{single-frequency-frequency}' },
Rank: { value: '{frequency-harmonic-rank}' },
},
{},
);
assert.deepEqual(fields, {
Dictionary: 'Main dictionary',
Frequency: '<ul style="text-align: left;"><li>Frequency: </li></ul>',
Rank: '42',
});
assert.ok('dictionaryIds' in request);
assert.deepEqual(request.dictionaryIds, {
JMdict: 'terms',
[characterDictionary]: 'names',
Frequency: 'frequency',
});
assert.deepEqual(
harness.messages.find((message) => message.type === 'hd_anki_submit')?.request,
request,
);
});
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
const harness = await createHarness();
await harness.run(
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
);
await harness.run(
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
);
const imported = harness.messages.find((message) => message.type === 'hd_import');
assert.equal(imported?.fileName, 'characters.zip');
assert.match(String(imported?.blobUrl), /^blob:/);
const removed = harness.messages.find((message) => message.type === 'hd_remove');
assert.equal(removed?.id, 'names');
});
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
const harness = await createHarness();
harness.setLoadingStatusReplies(2);
harness.setBusyEngineReplies(1);
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
assert.equal(dictionaries.length, 3);
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
assert.equal(tokens?.[0]?.headword, '食べる');
assert.equal(tokens?.[0]?.frequencyRank, 42);
});
@@ -0,0 +1,270 @@
// Hachidori exposes its own storage and engine protocol. Adapt it only inside
// SubMiner's hidden extension windows so the shared scanner keeps its matching,
// character-name and frequency semantics without changing Hachidori's pages.
import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori';
export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
(async () => {
if (globalThis.__subminerDictionarySendMessage) return;
const { createApiHost } = await import('./api-host.js');
await import('./reader-options.js');
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
const reply = await chrome.runtime.sendMessage({
target, type, requestId: crypto.randomUUID(), ...fields,
});
if (!reply || reply.ok !== true) {
throw new Error(reply?.error || 'Hachidori returned an invalid response');
}
return reply;
};
// Hachidori reports an empty library while its dictionaries load and rejects
// engine requests during imports. Wait for a settled engine so SubMiner never
// caches an empty dictionary list or a token-less scan.
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const waitForEngineReady = async () => {
const deadline = Date.now() + 120000;
for (;;) {
const reply = await send('hd_status').catch(() => null);
if (reply?.ready === true && reply.loading !== true) return;
if (Date.now() >= deadline) {
throw new Error(reply?.ready === true
? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready');
}
await sleep(250);
}
};
const engine = async (type, fields = {}, target) => {
for (let attempt = 0; ; attempt += 1) {
await waitForEngineReady();
try {
return await send(type, fields, target);
} catch (error) {
if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error;
await sleep(250);
}
}
};
const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state
?? { revision: 0, dictionaries: [] };
const readOptions = async () => {
const stored = (await chrome.storage.local.get('options')).options;
return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 };
};
globalThis.__subminerSetAnkiProxyUrl = async url => {
const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl;
await chrome.storage.local.set({ subminerAnkiProxyUrl: url });
return typeof previous === 'string' ? previous : null;
};
${HACHIDORI_ANKI_SETTINGS_SCRIPT}
const api = createApiHost({
engine: fields => engine(fields.type, fields),
render: fields => send(fields.type, fields, 'hachidori-anki-render'),
readDictionaries: async () => (await readState()).dictionaries,
readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled),
version: chrome.runtime.getManifest().version,
});
// Snapshot of the last projection SubMiner received, so a later write can
// be reduced to the fields SubMiner actually changed.
let lastProjection = null;
async function optionsGetFull() {
const [state, options] = await Promise.all([readState(), readOptions()]);
const revisions = { dictionaries: state.revision, options: options.revision };
lastProjection = {
revisions,
enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])),
server: options.anki.url,
deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])),
};
return {
profileCurrent: 0,
hachidoriRevisions: revisions,
profiles: [{ name: 'Hachidori', options: {
scanning: { length: options.scanLength },
dictionaries: state.dictionaries.map((entry, index) => ({
name: entry.title, alias: entry.displayName || entry.title,
id: index, enabled: entry.enabled !== false,
})),
anki: {
server: options.anki.url,
cardFormats: options.anki.templates.map(template => ({
id: template.id, type: 'term', enabled: true, deck: template.deck,
})),
},
} }],
};
}
async function setAllSettings(value) {
const projected = value.profiles?.[0]?.options;
const revisions = value.hachidoriRevisions;
if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection');
const base = lastProjection
&& lastProjection.revisions.dictionaries === revisions.dictionaries
&& lastProjection.revisions.options === revisions.options ? lastProjection : null;
const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true]));
const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck]));
for (let attempt = 0; ; attempt += 1) {
const [state, options] = await Promise.all([readState(), readOptions()]);
const current = state.revision === revisions.dictionaries && options.revision === revisions.options;
if (!current && !base) {
throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action');
}
// Once Hachidori has moved on, apply only SubMiner's own changes on top
// of the newer settings instead of replaying the stale projection.
const enabledFor = title => enabledByTitle[title] === true;
const dictionaries = state.dictionaries.map(entry =>
current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title)
? { ...entry, enabled: enabledFor(entry.title) } : entry);
const templates = options.anki.templates.map(template => {
const deck = deckByFormat[template.id];
return deck !== undefined && (current || base.deckByFormat[template.id] !== deck)
? { ...template, deck } : template;
});
const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url;
const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck };
try {
if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) {
await engine('hd_apply_state', { baseRevision: state.revision, dictionaries });
}
if (JSON.stringify(anki) !== JSON.stringify(options.anki)) {
await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker');
}
return true;
} catch (error) {
// A write raced another Hachidori change; re-read once and reapply the delta.
if (attempt > 0 || !base) throw error;
}
}
}
async function getTermFrequencies({ termReadingList, dictionaries }) {
const terms = [...new Set(termReadingList.map(pair => pair.term))];
const { results } = await api({ type: 'hd_api_term_entries', terms });
const frequencies = [];
for (const result of results) {
const term = terms[result.index];
const pairs = termReadingList.filter(pair => pair.term === term);
for (const entry of result.dictionaryEntries) {
for (const value of entry.frequencies) {
const headword = entry.headwords[value.headwordIndex];
if (!headword || headword.term !== term || !dictionaries.includes(value.dictionary)) continue;
if (!pairs.some(pair => pair.reading === null || pair.reading === headword.reading)) continue;
// Upstream does not expose the frequency entry's original reading.
// Keep its API flag and associate the value with the matched headword.
frequencies.push({ term, reading: headword.reading || null,
hasReading: value.hasReading, dictionary: value.dictionary,
frequency: value.frequency, displayValue: value.displayValue,
displayValueParsed: value.displayValueParsed });
}
}
}
return frequencies;
}
// Hachidori's public tokenize API emits display furigana without headwords.
// SubMiner's fallback requires one group per token and a dictionary form.
async function parseText({ text, scanLength }) {
const content = [];
let position = 0;
while (position < text.length) {
const rest = text.slice(position);
const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength });
const result = reply.results[0];
if (!result?.matched || !rest.startsWith(result.matched)) {
const character = String.fromCodePoint(rest.codePointAt(0));
content.push([{ text: character, reading: '' }]);
position += character.length;
continue;
}
const term = result.term;
// Keep the inflected ending in the reading, just as the main scanner does.
let stem = 0;
while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1;
const ending = term.expression.slice(stem);
const reading = stem > 0 && term.reading.endsWith(ending)
? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem)
: term.reading;
content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]);
position += result.matched.length;
}
return [{ source: 'scanning-parser', index: 0, content }];
}
async function invoke(action, params) {
switch (action) {
case 'optionsGetFull': return optionsGetFull();
case 'setAllSettings': return setAllSettings(params.value);
case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({
title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode,
}));
case 'termsFind': {
const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] });
return reply.results[0];
}
case 'parseText': return parseText(params);
case 'getTermFrequencies': return getTermFrequencies(params);
default: throw new Error('Unsupported Hachidori parser action: ' + action);
}
}
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
};
async function importArchive(blob, fileName) {
const blobUrl = URL.createObjectURL(blob);
try {
const reply = await engine('hd_import', { blobUrl, fileName });
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
} finally {
URL.revokeObjectURL(blobUrl);
}
}
globalThis.__subminerYomitanSettingsAutomation = {
ready: true,
async importDictionaryArchiveUrl(url) {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the dictionary archive');
await importArchive(await response.blob(), 'subminer-dictionary.zip');
},
async importDictionaryArchiveBase64(base64, fileName) {
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName);
},
async deleteDictionary(title) {
const dictionary = (await readState()).dictionaries.find(entry => entry.title === title);
if (dictionary) await engine('hd_remove', { id: dictionary.id, title });
},
};
globalThis.__subminerAddNote = async word => {
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
const result = lookup.results[0];
if (!result) return { noteId: null, duplicateNoteIds: [] };
// Match the dictionary context supplied by Hachidori's popup to its
// native glossary, alias, and frequency template renderers.
const { dictionaries } = await readState();
const frequencyModes = new Map(dictionaries.map(entry => [entry.title, entry.frequencyMode]));
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
const status = await send('hd_anki_status', {}, 'hachidori-anki');
const request = {
...result, term, generation: lookup.generation, sentence: word, searchQuery: word,
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
configKey: status.configKey, subminerEnrich: false,
dictionaryAliases: Object.fromEntries(dictionaries.filter(entry => entry.displayName)
.map(entry => [entry.title, entry.displayName])),
dictionaryIds: Object.fromEntries(dictionaries.map(entry => [entry.title, entry.id])),
frequencyDictionaries: dictionaries.filter(entry => entry.enabled !== false && entry.frequencyCount > 0)
.map(entry => entry.title),
captureUnavailable: ['screenshot', 'animation', 'audio'],
};
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
if (preflight.canAdd !== true) {
if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable');
return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] };
}
const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki');
if (submitted.state === 'added' || submitted.state === 'updated') {
return { noteId: submitted.noteId, duplicateNoteIds: [] };
}
if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] };
throw new Error(submitted.error || 'Hachidori could not confirm the note write');
};
})();
`;
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import test from 'node:test';
import * as vm from 'node:vm';
import {
countTermsFindLookups,
createDeps,
@@ -23,6 +24,102 @@ import {
upsertYomitanDictionarySettings,
} from './yomitan-parser-runtime';
test('Yomitan restores direct Anki after disabling its managed proxy without a page helper', async () => {
const options = { profiles: [{ options: { anki: { server: 'http://127.0.0.1:8765' } } }] };
let managedUrl: string | null = null;
const context = vm.createContext({
chrome: {
storage: {
local: {
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
managedUrl = value.subminerAnkiProxyUrl;
},
},
},
runtime: {
sendMessage: (
message: { action: string },
callback: (response: { result: unknown }) => void,
) => callback({ result: message.action === 'optionsGetFull' ? options : null }),
},
},
});
const deps = createDeps(async (script) =>
structuredClone(await vm.runInContext(script, context)),
);
const logger = { error: assert.fail };
assert.equal(
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, logger, {
forceOverride: true,
}),
true,
);
assert.equal(managedUrl, 'http://127.0.0.1:8766');
assert.equal(await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true);
assert.equal(options.profiles[0]?.options.anki.server, 'http://127.0.0.1:8765');
assert.equal(managedUrl, null);
});
for (const failure of ['optionsGetFull', 'setAllSettings', 'storageSet']) {
test(`Yomitan retries disabling its managed proxy after ${failure} fails`, async () => {
const proxyUrl = 'http://127.0.0.1:8766';
const directUrl = 'http://127.0.0.1:8765';
let options = { profiles: [{ options: { anki: { server: proxyUrl } } }] };
let managedUrl: string | null = proxyUrl;
let shouldFail = true;
const context = vm.createContext({
chrome: {
storage: {
local: {
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
if (shouldFail && failure === 'storageSet') throw new Error('Storage unavailable');
managedUrl = value.subminerAnkiProxyUrl;
},
},
},
runtime: {
sendMessage: (
message: { action: string; params?: { value: typeof options } },
callback: (response: { result?: unknown; error?: { message: string } }) => void,
) => {
if (shouldFail && message.action === failure) {
callback({ error: { message: 'Settings unavailable' } });
} else if (message.action === 'optionsGetFull') {
callback({ result: structuredClone(options) });
} else if (message.action === 'setAllSettings' && message.params) {
options = structuredClone(message.params.value);
callback({ result: null });
} else {
assert.fail(`Unexpected action: ${message.action}`);
}
},
},
},
});
const deps = createDeps(async (script) =>
structuredClone(await vm.runInContext(script, context)),
);
const errors: string[] = [];
const logger = { error: (message: string) => errors.push(message) };
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), false);
assert.equal(managedUrl, proxyUrl);
assert.equal(
options.profiles[0]?.options.anki.server,
failure === 'storageSet' ? directUrl : proxyUrl,
);
assert.equal(errors.length, 1);
shouldFail = false;
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), true);
assert.equal(options.profiles[0]?.options.anki.server, directUrl);
assert.equal(managedUrl, null);
assert.equal(errors.length, 1);
});
}
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
@@ -1,7 +1,19 @@
import type { BrowserWindow, Extension, Session } from 'electron';
import type { AnkiConnectConfig } from '../../../types';
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
import {
buildHachidoriSharingScript,
parseHachidoriHostStatus,
type HachidoriSharingRequest,
} from '../../../shared/hachidori-sharing';
import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
import {
HACHIDORI_PARSER_BRIDGE_SCRIPT,
HACHIDORI_SESSION_PARTITION,
} from './hachidori-parser-bridge';
import { selectYomitanParseTokens } from './parser-selection-stage';
import {
buildYomitanScanCallScript,
@@ -477,7 +489,7 @@ async function requestYomitanProfileMetadata(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -596,6 +608,18 @@ function logYomitanProfileDiagnostics(
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
function isHachidoriExtension(extension: Extension): boolean {
return extension.name === 'Hachidori';
}
// Without an explicit session, use the one the extension was loaded into:
// Hachidori lives in its own partition, Yomitan in the default session.
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
return isHachidoriExtension(extension)
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
: electron.session.defaultSession;
}
async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
@@ -606,19 +630,20 @@ async function ensureYomitanParserWindow(
return false;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const existingInitPromise = deps.getYomitanParserInitPromise();
if (existingInitPromise) {
return existingInitPromise;
}
const currentWindow = deps.getYomitanParserWindow();
if (currentWindow && !currentWindow.isDestroyed()) {
return true;
}
const initPromise = (async () => {
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession =
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const parserWindow = new BrowserWindow({
show: false,
width: 800,
@@ -649,11 +674,27 @@ async function ensureYomitanParserWindow(
});
try {
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
const readyPromise = deps.getYomitanParserReadyPromise();
if (readyPromise) {
await readyPromise;
}
if (isHachidoriExtension(yomitanExt)) {
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
} else {
// did-finish-load precedes the search page's asynchronous backend initialization.
await parserWindow.webContents.executeJavaScript(
`(async () => {
const deadline = Date.now() + 10000;
while (typeof window.__subminerAddNote !== 'function') {
if (Date.now() >= deadline) throw new Error('Yomitan search page initialization timed out');
await new Promise(resolve => setTimeout(resolve, 50));
}
})()`,
true,
);
}
// Eagerly install the scan runtime so the first subtitle line does not
// pay the install round trip; failures fall back to the per-request
// install-and-retry path.
@@ -696,8 +737,8 @@ async function createYomitanExtensionWindow(
return null;
}
const { BrowserWindow, session } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
const { BrowserWindow } = electron;
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
const window = new BrowserWindow({
show: false,
width: 1200,
@@ -740,6 +781,10 @@ async function invokeYomitanSettingsAutomation<T>(
}
try {
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
}
await settingsWindow.webContents.executeJavaScript(
`
(async () => {
@@ -892,7 +937,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -922,7 +967,7 @@ export async function requestYomitanParseResults(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1079,7 +1124,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1122,7 +1167,7 @@ async function fetchYomitanTermFrequencies(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1206,7 +1251,17 @@ function cacheFrequencyEntriesForPairs(
const key = makeTermReadingCacheKey(pair.term, pair.reading);
const exactEntries = groupedByPair.get(key);
const termEntries = groupedByTerm.get(pair.term) ?? [];
frequencyCache.set(key, exactEntries ?? termEntries);
// Untagged frequency rows apply to every reading. A term-only query must
// retain all readings, rather than selecting only its untagged rows.
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
frequencyCache.set(
key,
pair.reading === null
? termEntries
: exactEntries
? [...exactEntries, ...untaggedEntries]
: termEntries,
);
}
}
@@ -1340,6 +1395,7 @@ export async function syncYomitanDefaultAnkiServer(
options?: {
forceOverride?: boolean;
deck?: string;
ankiConfig?: AnkiConnectConfig;
},
): Promise<boolean> {
const normalizedTargetServer = serverUrl.trim();
@@ -1359,7 +1415,7 @@ export async function syncYomitanDefaultAnkiServer(
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1379,6 +1435,13 @@ export async function syncYomitanDefaultAnkiServer(
const targetServer = ${JSON.stringify(normalizedTargetServer)};
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
const forceOverride = ${forceOverride ? 'true' : 'false'};
const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)};
if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') {
return globalThis.__subminerSyncAnkiSettings({
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
});
}
const { subminerAnkiProxyUrl: previousManagedProxy } = await chrome.storage.local.get('subminerAnkiProxyUrl');
const optionsFull = await invoke("optionsGetFull", undefined);
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
if (profiles.length === 0) {
@@ -1405,8 +1468,11 @@ export async function syncYomitanDefaultAnkiServer(
let changed = false;
if (currentServer !== targetServer) {
const canReplaceCurrent =
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
if (!canReplaceCurrent) {
// A custom endpoint needs no settings change, but the proxy is no longer managed.
await chrome.storage.local.set({ subminerAnkiProxyUrl: null });
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
}
@@ -1444,17 +1510,28 @@ export async function syncYomitanDefaultAnkiServer(
}
}
if (changed) {
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
}
// Preserve the previous managed endpoint until settings are saved so failed switches can retry.
await chrome.storage.local.set({ subminerAnkiProxyUrl: forceOverride ? targetServer : null });
if (!changed) {
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
}
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
})();
`;
try {
const result = await parserWindow.webContents.executeJavaScript(script, true);
if (isObject(result) && result.pending === true) {
logger.info?.(
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
);
return false;
}
const updated =
typeof result === 'object' &&
result !== null &&
@@ -1564,7 +1641,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
(async () => {
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -1586,6 +1663,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
`;
}
export async function requestHachidoriSharing(
request: HachidoriSharingRequest,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
) {
const extension = deps.getYomitanExt();
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
const ready = await ensureYomitanParserWindow(deps, logger);
const window = deps.getYomitanParserWindow();
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
const reply: unknown = await window.webContents.executeJavaScript(
buildHachidoriSharingScript(request),
true,
);
const status = parseHachidoriHostStatus(reply);
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
return status;
}
async function invokeYomitanBackendAction<T>(
action: string,
params: unknown,
@@ -1704,6 +1800,7 @@ export async function importYomitanDictionaryFromZip(
zipPath: string,
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
hachidoriManagementUrl = '',
): Promise<boolean> {
const normalizedZipPath = zipPath.trim();
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
@@ -1711,6 +1808,30 @@ export async function importYomitanDictionaryFromZip(
return false;
}
const extension = deps.getYomitanExt();
if (extension && isHachidoriExtension(extension)) {
try {
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
if (host.kind === 'disconnected' || host.kind === 'unavailable')
throw new Error(host.message);
if (host.kind === 'connected') {
await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl);
const window = deps.getYomitanParserWindow();
if (window) clearYomitanParserCachesForWindow(window);
logger.info?.(
`Uploaded character dictionary to Hachidori host: ${path.basename(normalizedZipPath)}`,
);
return true;
}
} catch (error) {
logger.error(
'Hachidori character dictionary import failed:',
error instanceof Error ? error.message : String(error),
);
return false;
}
}
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
`
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
// Bump whenever the install script below changes so already-loaded parser
// windows re-install the new scan runtime instead of running the stale one.
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
export interface YomitanScanRequestParams {
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
}
const invoke = (action, params) =>
new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, params }, (response) => {
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
@@ -30,8 +30,17 @@ export function createDeps(
}
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
const storage: Record<string, unknown> = {};
return {
chrome: {
storage: {
local: {
get: async () => ({ ...storage }),
set: async (value: Record<string, unknown>) => {
Object.assign(storage, value);
},
},
},
runtime: {
lastError: null,
sendMessage: (
+3 -2
View File
@@ -16,6 +16,7 @@ type ExtensionCopyResult = {
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
extensionName?: 'yomitan' | 'hachidori';
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
@@ -156,7 +157,7 @@ export function ensureExtensionCopy(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
let shouldCopy = !fs.existsSync(targetDir);
if (!shouldCopy) {
@@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync(
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
@@ -188,6 +188,11 @@ export async function loadYomitanExtension(
deps.setYomitanSession(targetSession);
try {
if (!externalProfilePath) {
// Electron may retain old extension scripts after an update, across app restarts.
// Keep dictionaries/settings while ensuring the bundled worker loads current code.
await targetSession.clearStorageData({ storages: ['serviceworkers'] });
}
const extensions = targetSession.extensions;
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
+9 -5
View File
@@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
backend?: 'yomitan' | 'hachidori';
yomitanExt: Extension | null;
getExistingWindow: () => BrowserWindow | null;
setWindow: (window: BrowserWindow | null) => void;
@@ -23,6 +24,7 @@ type HyprlandSessionEnv = {
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
backend?: 'yomitan' | 'hachidori';
}
export function shouldInstallYomitanSettingsCloseButton(
@@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate(
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
export function buildYomitanSettingsCloseButtonScript(
backend: 'yomitan' | 'hachidori' = 'yomitan',
): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
@@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string {
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
@@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton(
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
@@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
const settingsWindow = new ElectronBrowserWindow({
title: 'Yomitan Settings',
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
width: 1200,
height: 800,
show: false,
@@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
});
setTimeout(() => {
+234 -30
View File
@@ -1,3 +1,4 @@
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2026 sudacode
@@ -33,6 +34,14 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
import {
createHachidoriExtensionRuntime,
getHachidoriSession,
} from './core/services/hachidori-extension';
import {
DICTIONARY_EXTERNAL_LINK_CHANNEL,
parseDictionaryExternalUrl,
} from './shared/dictionary-external-link';
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
@@ -383,6 +392,7 @@ import {
installLauncher as installCommandLineLauncher,
refreshManagedCommandLineLauncher,
} from './main/runtime/command-line-launcher';
import { applyLoginShellPath } from './main/runtime/login-shell-path';
import {
createWindowsMpvLaunchDeps,
getConfiguredWindowsMpvPathStatus,
@@ -891,14 +901,34 @@ const {
appState,
appLifecycleApp,
} = bootServices;
// Backend changes take effect on restart; windows and parser must share one session.
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
let hachidoriSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanExtension: Extension | null = null;
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
if (
activeDictionaryBackend !== 'hachidori' ||
event.senderFrame !== event.sender.mainFrame ||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
) {
throw new Error('Dictionary links are only available from the active overlay');
}
await shell.openExternal(parseDictionaryExternalUrl(value));
});
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
return [
appState.configSettingsWindow,
appState.syncUiWindow,
appState.yomitanSettingsWindow,
hachidoriSettingsWindow,
inactiveYomitanSettingsWindow,
appState.anilistSetupWindow,
appState.jellyfinSetupWindow,
appState.firstRunSetupWindow,
@@ -996,6 +1026,8 @@ const {
} = statsServerRuntime;
function requestAppQuit(): void {
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
@@ -1425,21 +1457,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<void> =
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 () => {
@@ -1508,10 +1564,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:
@@ -1522,7 +1578,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,
@@ -2011,7 +2069,24 @@ function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolea
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function getMpvPauseCommandValue(command: readonly (string | number)[]): 'yes' | 'no' | null {
if ((command[0] !== 'set_property' && command[0] !== 'set') || command[1] !== 'pause') {
return null;
}
return command[2] === 'yes' || command[2] === 'no' ? command[2] : null;
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
// Overlay auto-pause releases (popup closed, hover left) must not resume playback
// behind an open timing review; the review applies them when it closes. A pause
// request during the review keeps playback paused after it closes.
const pauseValue = getMpvPauseCommandValue(rawCommand);
if (pauseValue === 'no' && mediaTimingReviewRuntime.deferPlaybackResume()) {
return;
}
if (pauseValue === 'yes') {
mediaTimingReviewRuntime.cancelPlaybackResume();
}
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
@@ -2262,6 +2337,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
logger.error('Failed to auto-populate Hachidori Anki settings', error),
);
}
},
invalidateTokenizationCache: () => {
subtitleProcessingController.invalidateTokenizationCache();
@@ -2608,11 +2688,18 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
return false;
}
await ensureYomitanExtensionLoaded();
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
return await importYomitanDictionaryFromZip(
zipPath,
getYomitanParserRuntimeDeps(),
{
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
},
configService.getConfig().hachidori.externalHostManagementUrl,
);
},
dictionaryImportReplacesExisting: () =>
getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori',
deleteYomitanDictionary: async (dictionaryTitle) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
yomitanProfilePolicy.logSkippedWrite(
@@ -3437,6 +3524,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
dictionaryBackend: snapshot.dictionaryBackend,
hachidoriHost: snapshot.hachidoriHost,
canFinish: snapshot.canFinish,
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
@@ -3452,6 +3541,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (submission: FirstRunSetupSubmission) => {
if (
submission.action === 'link-hachidori-host' ||
submission.action === 'unlink-hachidori-host'
) {
try {
if (activeDictionaryBackend !== 'hachidori')
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
await ensureYomitanExtensionLoaded();
const status = await requestHachidoriSharing(
submission.action === 'link-hachidori-host'
? { type: 'hd_sharing_client_link', address: submission.address }
: { type: 'hd_sharing_client_unlink' },
getYomitanParserRuntimeDeps(),
logger,
);
firstRunSetupMessage =
status.kind === 'local'
? 'Using dictionaries installed in SubMiner.'
: status.kind === 'connected'
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
: status.message;
} catch (error) {
firstRunSetupMessage =
error instanceof Error ? error.message : 'Could not update the dictionary host.';
}
return;
}
if (submission.action === 'remove-legacy-plugin') {
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
firstRunSetupMessage = snapshot.message;
@@ -3492,8 +3608,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
firstRunSetupMessage = openDictionarySettings()
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
return;
}
@@ -5153,6 +5269,41 @@ function initializeOverlayRuntime(): void {
}
function openYomitanSettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
return false;
}
inactiveYomitanLoad ??= inactiveYomitanExtension
? Promise.resolve(inactiveYomitanExtension)
: loadYomitanExtensionCore({
userDataPath: USER_DATA_PATH,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: (extension) => {
inactiveYomitanExtension = extension;
},
setYomitanSession: () => {},
}).finally(() => {
inactiveYomitanLoad = null;
});
void (
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
)
.then((extension) =>
openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: () => inactiveYomitanSettingsWindow,
setWindow: (window) => {
inactiveYomitanSettingsWindow = window;
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
return true;
}
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
logger.warn(
@@ -5165,6 +5316,39 @@ function openYomitanSettings(): boolean {
return true;
}
function openHachidoriSettings(): void {
void (
activeDictionaryBackend === 'hachidori'
? ensureYomitanExtensionLoaded()
: hachidoriExtensionRuntime.ensureLoaded()
)
.then((extension) =>
openYomitanSettingsWindow({
backend: 'hachidori',
yomitanExt: extension,
yomitanSession: getHachidoriSession(),
getExistingWindow: () => hachidoriSettingsWindow,
setWindow: (window) => {
hachidoriSettingsWindow = window;
},
onWindowClosed: () => {
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
}
},
}),
)
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
}
function openDictionarySettings(): boolean {
if (activeDictionaryBackend === 'hachidori') {
openHachidoriSettings();
return true;
}
return openYomitanSettings();
}
const { exportLogsFromTray } = createLogExportTrayRuntime({
flushMpvLog: () => flushMpvLog(),
logInfo: (message) => logger.info(message),
@@ -5192,7 +5376,7 @@ const {
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
registerGlobalShortcutsCore,
toggleVisibleOverlay: () => toggleVisibleOverlay(),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
isDev,
getMainWindow: () => overlayManager.getMainWindow(),
}),
@@ -5703,7 +5887,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
}
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
openYomitanSettings: () => openDictionarySettings(),
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
@@ -6150,6 +6334,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6285,7 +6470,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);
@@ -6325,7 +6510,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
isOverlayVisible: (windowKind) =>
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
getYomitanSession: () => appState.yomitanSession,
getYomitanSession: () =>
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
tryHandleOverlayShortcutLocalFallback: (input) =>
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
@@ -6413,6 +6599,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
getDictionaryBackend: () => activeDictionaryBackend,
openHachidoriSettings: () => openHachidoriSettings(),
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
openSyncUiWindow: () => openSyncUiWindowHandler(),
@@ -6457,12 +6645,28 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
});
const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
externalProfilePath:
activeDictionaryBackend === 'yomitan'
? configService.getConfig().yomitan.externalProfilePath
: '',
logInfo: (message) => logger.info(message),
});
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore,
loadYomitanExtensionCore: async (deps) => {
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
try {
const extension = await hachidoriExtensionRuntime.ensureLoaded();
deps.setYomitanExtension(extension);
deps.setYomitanSession(getHachidoriSession());
return extension;
} catch (error) {
logger.error('Failed to load Hachidori extension:', error);
deps.setYomitanExtension(null);
deps.setYomitanSession(null);
return null;
}
},
userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath,
getYomitanParserWindow: () => appState.yomitanParserWindow,
+2
View File
@@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext {
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
ui: {
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
openHachidoriSettings: context.openHachidoriSettings,
openConfigSettingsWindow: context.openConfigSettingsWindow,
openSyncUiWindow: context.openSyncUiWindow,
cycleSecondarySubMode: context.cycleSecondarySubMode,
+2
View File
@@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams {
ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
@@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps(
ui: {
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
openHachidoriSettings: params.ui.openHachidoriSettings,
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
openSyncUiWindow: params.ui.openSyncUiWindow,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
+54 -2
View File
@@ -18,6 +18,7 @@ type MockWindow = {
contentReady: boolean;
documentLoaded: boolean;
loadCallbacks: Array<() => void>;
stopLoadingCallbacks: Array<() => void>;
readyToShowCallbacks: Array<() => void>;
};
@@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & {
destroy: () => void;
focus: () => void;
emitDidFinishLoad: () => void;
emitDidStopLoading: () => void;
emitReadyToShow: () => void;
once: (event: 'ready-to-show', cb: () => void) => void;
webContents: {
@@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & {
getURL: () => string;
send: (channel: string, payload?: unknown) => void;
isFocused: () => boolean;
once: (event: 'did-finish-load', cb: () => void) => void;
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
focus: () => void;
};
} {
@@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & {
contentReady: true,
documentLoaded: true,
loadCallbacks: [],
stopLoadingCallbacks: [],
readyToShowCallbacks: [],
};
const window = {
@@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & {
callback();
}
},
emitDidStopLoading: () => {
state.loading = false;
const callbacks = state.stopLoadingCallbacks.splice(0);
for (const callback of callbacks) {
callback();
}
},
emitReadyToShow: () => {
const callbacks = state.readyToShowCallbacks.splice(0);
for (const callback of callbacks) {
@@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & {
},
focused: false,
isFocused: () => state.webContentsFocused,
once: (_event: 'did-finish-load', cb: () => void) => {
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
if (event === 'did-stop-loading') {
state.stopLoadingCallbacks.push(cb);
return;
}
state.loadCallbacks.push(cb);
},
focus: () => {
@@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
assert.deepEqual(window.sent, [['runtime-options:open']]);
});
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
// before it; only did-stop-loading sees the settled state.
const window = createMockWindow();
window.loading = true;
window.url = '';
window.contentReady = false;
window.documentLoaded = false;
const runtime = createOverlayModalRuntimeService({
getMainWindow: () => null,
getModalWindow: () => null,
createModalWindow: () => window as never,
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
setModalWindowBounds: () => {},
});
assert.equal(
runtime.sendToActiveOverlayWindow(
'media-timing-review:open',
{ reviewId: 'r1' },
{
restoreOnModalClose: 'media-timing-review',
preferModalWindow: true,
},
),
true,
);
assert.deepEqual(window.sent, []);
window.contentReady = true;
window.emitReadyToShow();
window.url = 'file:///overlay/index.html?layer=modal';
window.documentLoaded = true;
window.emitDidFinishLoad();
assert.deepEqual(window.sent, []);
window.emitDidStopLoading();
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
});
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
const window = createMockWindow();
window.contentReady = false;
+5
View File
@@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService(
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
modalWindowPrimeListenersRegistered.add(modalWindow);
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
}
return true;
@@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService(
// A hidden macOS panel may not emit ready-to-show until it is presented. The
// renderer can safely receive IPC as soon as its document has finished loading.
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
// fire even earlier), so a window created for this send would drop the message
// without the did-stop-loading pass that follows once the load state settles.
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
deliver(() => isWindowLoadedForIpc(window));
};
@@ -31,6 +31,45 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
return { promise, resolve };
}
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
for (const succeeds of [true, false]) {
let imported = false;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath: makeTempDir(),
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/replacement.zip',
revision: 'new',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
}),
getYomitanDictionaryInfo: async () => [
{ title: 'SubMiner Character Dictionary', revision: 'old' },
],
dictionaryImportReplacesExisting: () => true,
importYomitanDictionary: async () => {
imported = true;
return succeeds;
},
deleteYomitanDictionary: async () => {
assert.fail('must keep the old dictionary until replacement succeeds');
},
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
if (succeeds) await runtime.runSyncNow();
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
assert.equal(imported, true);
}
});
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
const userDataPath = makeTempDir();
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
@@ -80,6 +80,7 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
waitForYomitanMutationReady?: () => Promise<void>;
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
dictionaryImportReplacesExisting?: () => boolean;
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
upsertYomitanDictionarySettings: (
dictionaryTitle: string,
@@ -669,7 +670,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
const importTimeoutMs = resolveImportTimeoutMs(
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
);
if (existing !== null) {
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
await withTimeout(
`deleteYomitanDictionary(${dictionaryTitle})`,
deps.deleteYomitanDictionary(dictionaryTitle),
@@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => {
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
openSyncUiWindow: () => {},
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
openHachidoriSettings: () => {},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
openSyncUiWindow: () => {},
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: () => deps.openYomitanSettings(),
openHachidoriSettings: () => deps.openHachidoriSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
openSyncUiWindow: () => deps.openSyncUiWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
@@ -56,6 +56,7 @@ function createDeps() {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
+2
View File
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
openYomitanSettings: () => void;
openHachidoriSettings: () => void;
openConfigSettingsWindow: () => void;
openSyncUiWindow: () => void;
cycleSecondarySubMode: () => void;
@@ -134,6 +135,7 @@ export function createCliCommandContext(
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
openYomitanSettings: deps.openYomitanSettings,
openHachidoriSettings: deps.openHachidoriSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openSyncUiWindow: deps.openSyncUiWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runUpdateCommand: async () => {},
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openHachidoriSettings: () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
openSyncUiWindow: () => {},
@@ -6,6 +6,11 @@ import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
import {
createDefaultSetupState,
getSetupStatePath,
readSetupState,
} from '../../shared/setup-state';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
yomitan: false,
hachidori: false,
settings: false,
syncWindow: false,
setup: false,
@@ -762,3 +768,145 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
]);
});
});
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
let dictionaryCount = 0;
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => dictionaryCount,
isExternalYomitanConfigured: () => true,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.dictionaryBackend, 'hachidori');
assert.equal(initial.state.dictionaryBackend, 'hachidori');
assert.equal(initial.canFinish, false);
assert.equal(initial.externalYomitanConfigured, false);
assert.equal(initial.state.status, 'incomplete');
dictionaryCount = 1;
const completed = await hachidori.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.dictionaryBackend, 'hachidori');
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
assert.equal(hachidori.isSetupCompleted(), true);
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
// Switching back never repeats setup for a backend that already finished.
const yomitanAgain = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const restored = await yomitanAgain.ensureSetupStateInitialized();
assert.equal(restored.state.status, 'completed');
assert.equal(restored.state.dictionaryBackend, 'yomitan');
assert.equal(yomitanAgain.isSetupCompleted(), true);
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
});
});
test('reopening setup for legacy plugin cleanup preserves both backend completions', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
detectLegacyMpvPluginCandidates: () => [
{ path: '/tmp/mpv/scripts/subminer.lua', kind: 'file' },
],
});
await yomitan.ensureSetupStateInitialized();
const reopened = await yomitan.markSetupInProgress();
assert.equal(reopened.state.status, 'in_progress');
assert.deepEqual(reopened.state.completedDictionaryBackends, ['yomitan']);
assert.equal(yomitan.isSetupCompleted(), false);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
const switched = await hachidori.ensureSetupStateInitialized();
assert.deepEqual(switched.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
const restored = await yomitan.getSetupStatus();
assert.equal(restored.state.status, 'completed');
});
});
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
await withTempDir(async (configDir) => {
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
fs.writeFileSync(
getSetupStatePath(configDir),
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
);
const hachidori = createFirstRunSetupService({
configDir,
getDictionaryBackend: () => 'hachidori',
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
});
const initial = await hachidori.ensureSetupStateInitialized();
assert.equal(initial.state.status, 'incomplete');
const stored = readSetupState(getSetupStatePath(configDir));
assert.equal(stored?.dictionaryBackend, 'hachidori');
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
const yomitan = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => false,
});
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
});
});
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
await withTempDir(async (dir) => {
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 2,
};
const service = createFirstRunSetupService({
configDir: dir,
getDictionaryBackend: () => 'hachidori',
getHachidoriHostStatus: async () => host,
getYomitanDictionaryCount: async () => 7,
detectPluginInstalled: () => false,
});
let snapshot = await service.getSetupStatus();
assert.equal(snapshot.dictionaryCount, 2);
assert.equal(snapshot.canFinish, true);
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' };
snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.canFinish, false);
assert.equal(snapshot.dictionaryCount, 0);
assert.equal(snapshot.state.status, 'incomplete');
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
host = {
kind: 'connected',
address: 'ws://127.0.0.1:8771/link',
name: 'Docker',
dictionaryCount: 0,
};
assert.equal((await service.getSetupStatus()).canFinish, false);
host = { kind: 'local' };
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
assert.equal((await service.getSetupStatus()).canFinish, true);
});
});
+101 -5
View File
@@ -1,7 +1,10 @@
import fs from 'node:fs';
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
import {
createDefaultSetupState,
getDefaultConfigFilePaths,
getSetupStateDictionaryBackend,
hasCompletedSetupForBackend,
getSetupStatePath,
isSetupCompleted,
readSetupState,
@@ -11,6 +14,7 @@ import {
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
import type { DictionaryBackend } from '../../types/config';
import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
}
export interface SetupStatusSnapshot {
dictionaryBackend: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.yomitan ||
args.hachidori ||
args.settings ||
args.show ||
args.hide ||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
}
export function getFirstRunSetupCompletionMessage(snapshot: {
dictionaryBackend?: DictionaryBackend;
hachidoriHost?: HachidoriHostStatus;
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
if (!snapshot.configReady) {
return 'Create or provide the config file before finishing setup.';
}
if (
snapshot.hachidoriHost?.kind === 'disconnected' ||
snapshot.hachidoriHost?.kind === 'unavailable'
) {
return snapshot.hachidoriHost.message;
}
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
}
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
return 'Install at least one Yomitan dictionary before finishing setup.';
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
return `Install at least one ${name} dictionary before finishing setup.`;
}
return null;
}
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
async function resolveYomitanSetupStatus(deps: {
configFilePaths: { jsoncPath: string; jsonPath: string };
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
}): Promise<{
configReady: boolean;
dictionaryCount: number;
externalYomitanConfigured: boolean;
hachidoriHost?: HachidoriHostStatus;
}> {
const configReady =
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
const externalYomitanConfigured =
deps.getDictionaryBackend?.() !== 'hachidori' &&
(deps.isExternalYomitanConfigured?.() ?? false);
const hachidoriHost =
deps.getDictionaryBackend?.() === 'hachidori'
? await deps.getHachidoriHostStatus?.().catch(
(error: unknown): HachidoriHostStatus => ({
kind: 'unavailable',
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
}),
)
: undefined;
if (hachidoriHost && hachidoriHost.kind !== 'local') {
return {
configReady,
externalYomitanConfigured: false,
hachidoriHost,
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
};
}
if (configReady && externalYomitanConfigured) {
return {
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
return {
configReady,
hachidoriHost,
dictionaryCount: await deps.getYomitanDictionaryCount(),
externalYomitanConfigured,
};
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
getDictionaryBackend?: () => DictionaryBackend;
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
isExternalYomitanConfigured?: () => boolean;
detectPluginInstalled: () => boolean | Promise<boolean>;
detectLegacyMpvPluginCandidates?: () =>
@@ -283,8 +328,47 @@ export function createFirstRunSetupService(deps: {
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
const readStoredState = (): SetupState =>
readSetupState(setupStatePath) ?? createDefaultSetupState();
// The file records one backend's status at a time. Project it onto the active
// backend: a backend that finished before stays completed, any other stays
// incomplete until its own dictionaries are ready.
const projectState = (stored: SetupState): SetupState => {
const backend = getDictionaryBackend();
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
// Legacy files carry their completion only as the recorded status; keep it.
const storedBackend = getSetupStateDictionaryBackend(stored);
const completedDictionaryBackends = [
...new Set([
...(stored.completedDictionaryBackends ?? []),
...(stored.status === 'completed' ? [storedBackend] : []),
]),
];
if (storedBackend === backend) return { ...stored, completedDictionaryBackends };
return {
...stored,
dictionaryBackend: backend,
completedDictionaryBackends,
status: finishedBefore ? 'completed' : 'incomplete',
completedAt: finishedBefore ? stored.completedAt : null,
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
yomitanSetupMode: finishedBefore ? 'internal' : null,
lastSeenYomitanDictionaryCount: 0,
};
};
const readState = (): SetupState => projectState(readStoredState());
const writeState = (state: SetupState): SetupState => {
const backend = getDictionaryBackend();
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
state = {
...state,
dictionaryBackend: backend,
completedDictionaryBackends:
state.status === 'completed'
? [...others, backend]
: (state.completedDictionaryBackends ?? []),
};
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
@@ -292,10 +376,12 @@ export function createFirstRunSetupService(deps: {
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const { configReady, dictionaryCount, externalYomitanConfigured } =
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const pluginInstalled = await deps.detectPluginInstalled();
@@ -314,6 +400,8 @@ export function createFirstRunSetupService(deps: {
installedWindowsMpvShortcuts,
);
return {
dictionaryBackend: getDictionaryBackend(),
hachidoriHost,
configReady,
dictionaryCount,
canFinish: isYomitanSetupSatisfied({
@@ -353,11 +441,19 @@ export function createFirstRunSetupService(deps: {
return {
ensureSetupStateInitialized: async () => {
const state = readState();
const stored = readStoredState();
// Persist the active backend stamp so the launcher can tell which backend
// the running app gates playback on.
const state =
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
? stored
: writeState(projectState(stored));
const { configReady, dictionaryCount, externalYomitanConfigured } =
await resolveYomitanSetupStatus({
configFilePaths,
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
getDictionaryBackend,
getHachidoriHostStatus: deps.getHachidoriHostStatus,
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
});
const canFinish = isYomitanSetupSatisfied({

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