mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203a61a20f
|
||
|
|
a681bbaf25
|
||
|
|
6219a81144 | ||
|
|
cc9af79d6e | ||
|
|
d3a94536a8
|
||
|
|
3068d02f89
|
||
|
|
9150a60630 | ||
|
|
3549e0ed93 | ||
|
|
b0a8b6b3fc
|
||
|
|
153bc5d4bf
|
||
|
|
cbaa4626e1 | ||
|
|
e690ffba07
|
||
|
|
d047e3e0a4 |
No files matched your search
@@ -78,6 +78,10 @@ jobs:
|
||||
- name: End-to-end suite
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Dictionary backend windows (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a bun run test:dictionary:electron
|
||||
|
||||
- name: Upload session logs (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -67,26 +67,26 @@ jobs:
|
||||
- name: Build unversioned AppImage
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
appimages=(release/SubMiner-*.AppImage)
|
||||
appimages=(dist/release/SubMiner-*.AppImage)
|
||||
if [ "${#appimages[@]}" -eq 0 ]; then
|
||||
echo "No versioned AppImage found to create unversioned artifact."
|
||||
ls -la release
|
||||
ls -la dist/release
|
||||
exit 1
|
||||
fi
|
||||
cp "${appimages[0]}" release/SubMiner.AppImage
|
||||
cp "${appimages[0]}" dist/release/SubMiner.AppImage
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
|
||||
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
dist/release/*.AppImage
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
@@ -159,17 +159,17 @@ jobs:
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
dist/release/*.dmg
|
||||
dist/release/*.zip
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
@@ -224,15 +224,15 @@ jobs:
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: bun run test:package "release/win-unpacked/resources"
|
||||
run: bun run test:package "dist/release/win-unpacked/resources"
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
@@ -44,19 +44,19 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -90,13 +90,15 @@ jobs:
|
||||
|
||||
- name: Package optional assets bundle
|
||||
run: |
|
||||
tar -czf "release/subminer-assets.tar.gz" \
|
||||
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||
resources/bun/licenses
|
||||
packaging/bun/licenses
|
||||
|
||||
- name: Package Bun corresponding source
|
||||
run: bun scripts/package-bun-source.mjs
|
||||
@@ -104,16 +106,16 @@ jobs:
|
||||
- name: Generate checksums
|
||||
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=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No release artifacts found for checksum generation."
|
||||
exit 1
|
||||
fi
|
||||
: > release/SHA256SUMS.txt
|
||||
: > dist/release/SHA256SUMS.txt
|
||||
for file in "${files[@]}"; do
|
||||
printf '%s %s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"${file##*/}" >> release/SHA256SUMS.txt
|
||||
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||
done
|
||||
|
||||
- name: Get version from tag
|
||||
@@ -124,12 +126,12 @@ jobs:
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
if [ ! -s release/prerelease-notes.md ]; then
|
||||
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
|
||||
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||
exit 1
|
||||
fi
|
||||
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
||||
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -142,15 +144,15 @@ jobs:
|
||||
|
||||
shopt -s nullglob
|
||||
artifacts=(
|
||||
release/*.AppImage
|
||||
release/*.dmg
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.tar.gz
|
||||
release/*.tar.gz.sha256
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/SHA256SUMS.txt
|
||||
dist/release/*.AppImage
|
||||
dist/release/*.dmg
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/*.tar.gz
|
||||
dist/release/*.tar.gz.sha256
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
dist/release/SHA256SUMS.txt
|
||||
dist/launcher/subminer
|
||||
dist/launcher/subminer.cmd
|
||||
)
|
||||
@@ -165,14 +167,14 @@ jobs:
|
||||
--draft \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
else
|
||||
gh release create "$RELEASE_VERSION" \
|
||||
--draft \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
fi
|
||||
|
||||
for asset in "${artifacts[@]}"; do
|
||||
@@ -183,4 +185,4 @@ jobs:
|
||||
--draft=false \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
@@ -45,19 +45,19 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -91,13 +91,15 @@ jobs:
|
||||
|
||||
- name: Package optional assets bundle
|
||||
run: |
|
||||
tar -czf "release/subminer-assets.tar.gz" \
|
||||
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||
resources/bun/licenses
|
||||
packaging/bun/licenses
|
||||
|
||||
- name: Package Bun corresponding source
|
||||
run: bun scripts/package-bun-source.mjs
|
||||
@@ -105,16 +107,16 @@ jobs:
|
||||
- name: Generate checksums
|
||||
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=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No release artifacts found for checksum generation."
|
||||
exit 1
|
||||
fi
|
||||
: > release/SHA256SUMS.txt
|
||||
: > dist/release/SHA256SUMS.txt
|
||||
for file in "${files[@]}"; do
|
||||
printf '%s %s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"${file##*/}" >> release/SHA256SUMS.txt
|
||||
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||
done
|
||||
|
||||
- name: Get version from tag
|
||||
@@ -152,24 +154,24 @@ jobs:
|
||||
gh release edit "$RELEASE_VERSION" \
|
||||
--draft=false \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/release-notes.md
|
||||
--notes-file packaging/release-notes/release-notes.md
|
||||
else
|
||||
gh release create "$RELEASE_VERSION" \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/release-notes.md
|
||||
--notes-file packaging/release-notes/release-notes.md
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
artifacts=(
|
||||
release/*.AppImage
|
||||
release/*.dmg
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.tar.gz
|
||||
release/*.tar.gz.sha256
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/SHA256SUMS.txt
|
||||
dist/release/*.AppImage
|
||||
dist/release/*.dmg
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/*.tar.gz
|
||||
dist/release/*.tar.gz.sha256
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
dist/release/SHA256SUMS.txt
|
||||
dist/launcher/subminer
|
||||
dist/launcher/subminer.cmd
|
||||
)
|
||||
|
||||
+1
-5
@@ -8,11 +8,6 @@ node_modules/
|
||||
# Electron build output
|
||||
out/
|
||||
dist/
|
||||
release/*
|
||||
!release/
|
||||
!release/release-notes.md
|
||||
!release/prerelease-notes.md
|
||||
build/yomitan/
|
||||
coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
@@ -41,6 +36,7 @@ environment.toml
|
||||
.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
# Local test media and scripts
|
||||
tests/*
|
||||
.worktrees/
|
||||
.tmp/
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
coverage
|
||||
vendor
|
||||
*.log
|
||||
src/core/services/tokenizer/__fixtures__/golden/*.json
|
||||
# Reviewed release text and vendored license texts; never reformat
|
||||
packaging/release-notes
|
||||
packaging/bun/licenses
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
### Changed
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
### Added
|
||||
- **Hachidori Dictionary Backend**:
|
||||
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
|
||||
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
||||
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
|
||||
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
|
||||
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
|
||||
|
||||
### Fixed
|
||||
- **Jellyfin**:
|
||||
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
|
||||
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
|
||||
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
|
||||
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
|
||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
|
||||
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
|
||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
|
||||
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
||||
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
|
||||
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
|
||||
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
|
||||
|
||||
### Docs
|
||||
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
|
||||
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
|
||||
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
|
||||
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
|
||||
- **Dead Code Removal**:
|
||||
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
|
||||
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
|
||||
- Typecheck now runs with `noUnusedLocals`.
|
||||
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||
|
||||
# If building from source, the AppImage will typically land in release/.
|
||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||
PRERELEASE_NOTES := release/prerelease-notes.md
|
||||
# If building from source, the AppImage will typically land in dist/release/.
|
||||
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
|
||||
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
|
||||
|
||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -57,7 +56,7 @@ help:
|
||||
" build-linux Build Linux AppImage" \
|
||||
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
||||
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
||||
" clean Remove build artifacts (dist/, AppImage, binary)" \
|
||||
" dev-start Build and launch local Electron app" \
|
||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||
@@ -165,15 +164,7 @@ build-launcher:
|
||||
|
||||
clean:
|
||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
||||
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
||||
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
||||
rm -rf dist release && \
|
||||
install -d release && \
|
||||
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
||||
else \
|
||||
rm -rf dist release; \
|
||||
fi
|
||||
@rm -rf dist
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
|
||||
generate-config: ensure-bun
|
||||
@@ -229,7 +220,7 @@ install-linux: build-launcher
|
||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
||||
else \
|
||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||
printf '%s\n' " Build one with: make build"; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||
@@ -252,7 +243,7 @@ install-macos: build-launcher
|
||||
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
||||
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
||||
else \
|
||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
|
||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
|
||||
printf '%s\n' " Build one with: make build"; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Look up words with Yomitan, mine them to Anki, and track your immersion without leaving mpv
|
||||
Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -25,6 +25,8 @@ Look up words with Yomitan, mine them to Anki, and track your immersion without
|
||||
|
||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||
|
||||
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/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>
|
||||
@@ -36,7 +38,7 @@ Hover over any word in the subtitles to open the full Yomitan popup with definit
|
||||
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
|
||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
@@ -46,7 +48,7 @@ Create an Anki card from the exact playback moment with one key press, click, or
|
||||
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency colors, character portraits, and JLPT underlines">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
@@ -199,7 +201,7 @@ The AppImage is all you need. First-run setup can install the optional `subminer
|
||||
|
||||
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility**, or the overlay cannot follow the mpv window. If macOS blocks the first launch, right-click the app and choose **Open**.
|
||||
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility** (named **Device Control and Data Access** on macOS 27) so the overlay can track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized. If macOS blocks the first launch, right-click the app and choose **Open**.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -265,7 +267,8 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "43.7.7",
|
||||
"electron": "43.8.3",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.1",
|
||||
"undici": "7.30.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,6 +37,7 @@
|
||||
"brace-expansion": "5.0.11",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.7",
|
||||
"follow-redirects": "1.16.1",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.2",
|
||||
@@ -45,7 +46,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.1",
|
||||
"undici": "7.30.1",
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -304,7 +305,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@43.7.7", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="],
|
||||
"electron": ["electron@43.8.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-KgvSm3bHXz96hSXzqxyRjLeli80rIdszbDQFKlRSfckmGazTPCH/CxUN31Dp9c5rFKTQSNf9gPJRWl1yWNB3pA=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
@@ -350,7 +351,7 @@
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
"follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
@@ -616,7 +617,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
|
||||
"undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
|
||||
+2
-2
@@ -44,11 +44,11 @@ How fragments turn into a release:
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
Prerelease notes:
|
||||
|
||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
|
||||
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
||||
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Added a headless end-to-end harness (`bun run test:e2e`, `bun run e2e`) that boots the built app with a real mpv, a synthetic clip, a fixture Yomitan dictionary, and a fake AnkiConnect on an offscreen display.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Fixed `subminer jellyfin -p` reporting "No Jellyfin libraries found." when SubMiner was already running (for example right after `subminer jellyfin` login). The app now hands library, item, and preview-auth results to the launcher directly instead of through log files, so picker image previews work again and real errors (expired session, unreachable server) are shown instead of an empty list.
|
||||
- The rofi "Jellyfin Search (optional)" step after picking a library is now a compact input box with a "Type to search, or press Enter to browse all" hint instead of a blank list.
|
||||
- Playing from the terminal (`subminer jellyfin -p`) now reports watch progress to Jellyfin even when cast discovery is off, so Jellyfin remembers where you stopped and marks finished episodes as played. Previously progress was only sent while the cast connection was active.
|
||||
- When `subminer jellyfin -p` has to start SubMiner itself, the app now shows its tray icon and quits when you close mpv, the same as playing a local file. Before, it had no tray icon and kept running hidden after playback. A SubMiner that was already running is still reused and left running.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: jellyfin
|
||||
|
||||
- Corrected the Jellyfin docs: SubMiner only joins the cast menu at startup when `startupWarmups.jellyfinRemoteSession` is on (off by default) along with `jellyfin.remoteControlAutoConnect`. Otherwise tick **Jellyfin Discovery** in the tray.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Japanese subtitle generation now works while playing a Jellyfin episode. SubMiner reads the original file from the server, saves the subtitles to a cache folder, and loads them into mpv. Direct-play and transcoded playback are both supported.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keyboard-only mode no longer swallows `h`, `l`, and the left/right arrows while a modal is open or a text field has focus, so typing into fields like the Jimaku or TsukiHime title works and no longer seeks mpv. Pressing `Ctrl/Cmd+Shift+Y` (or the controller toggle) inside a modal no longer turns on a hidden keyboard-only mode in the modal window.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: launcher
|
||||
|
||||
- Removed `SUBMINER_JIMAKU_API_KEY`, `SUBMINER_JIMAKU_API_KEY_COMMAND`, and `SUBMINER_JIMAKU_API_BASE_URL` from the development docs' environment variable table. The launcher never used them; set the Jimaku key with `jimaku.apiKey` / `jimaku.apiKeyCommand` in the config instead.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: internal
|
||||
area: cleanup
|
||||
|
||||
- Removed dead code: the unused launcher Jimaku client (the `SUBMINER_JIMAKU_*` env vars it read were documented but had no effect), the mpv plugin's hover-highlight module (no sender since Overlay 2.0; custom mpv scripts that still send `subminer-hover-token` or `yomipv-hover-token` are now ignored), the never-read `anilist.characterDictionary.refreshTtlHours` / `evictionPolicy` config keys, unused root dependencies (eslint, geist fonts), and a committed draft plan doc.
|
||||
- Typecheck now runs with `noUnusedLocals`; removed the unused locals, imports, and the Windows overlay focus-handoff values they fed, which were computed but never used.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: overlay
|
||||
|
||||
- Removed 16 overlay preload/IPC methods that nothing called, plus their main-process handlers. SubMiner no longer observes mpv's `sub-text/ass` or sends every ASS line to overlay windows that never listened.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: setup
|
||||
|
||||
- First-run setup now labels a missing command-line launcher "Not set up" instead of "Not installed", so the AppImage catalog test no longer reads the setup window as an error screen.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The open subtitle sidebar no longer re-fetches the whole subtitle list and re-queries mpv about 12 times a second. It polls only the current time and line, and reloads the list when the subtitle track, file, or parsed cues change (plus a slow ~2 s fallback), cutting idle sidebar IPC and renderer work during playback.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Subtitle lines recorded before halfwidth katakana widening now show fullwidth katakana in the stats dashboard (word/kanji occurrences, sentence search), and cards mined from stats history get the fullwidth sentence and word.
|
||||
- Sentence search matches katakana in either width, so searching アンタ also finds older lines stored as アンタ.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Hyprland and Sway window tracking no longer freezes the app when `hyprctl` or `swaymsg` is slow or hangs: polls now run in the background with a timeout and no longer pile up.
|
||||
- The Hyprland/Sway trackers read each mpv process's command line once instead of spawning `ps` on every poll.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Halfwidth katakana in subtitles (common in broadcast-derived Japanese subs, e.g. アンタ, ダキニ) now displays as normal fullwidth katakana, so lines no longer mix squashed and normal-width text. Mined sentences and stats get the fullwidth form too.
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
@@ -734,10 +741,10 @@
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
|
||||
@@ -15,9 +15,9 @@ If you changed AnkiConnect's port, set `ankiConnect.url` to match.
|
||||
|
||||
## How cards get filled
|
||||
|
||||
When Yomitan adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. Yomitan sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
|
||||
|
||||
Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
|
||||
@@ -42,6 +42,20 @@ Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to Anki
|
||||
|
||||
- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
|
||||
- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
|
||||
- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio.
|
||||
|
||||
### Hachidori settings from SubMiner
|
||||
|
||||
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
|
||||
|
||||
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
|
||||
- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written.
|
||||
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
|
||||
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
|
||||
|
||||
Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry.
|
||||
|
||||
Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this.
|
||||
|
||||
### Proxy troubleshooting
|
||||
|
||||
@@ -87,6 +101,8 @@ If cards are not getting filled:
|
||||
|
||||
Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
|
||||
|
||||
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
||||
|
||||
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note, so point `fields.audio` at a separate field such as `SentenceAudio`. The default, `ExpressionAudio`, is the field many note types use for Yomitan's word audio, so leaving it would overwrite that audio.
|
||||
|
||||
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
||||
@@ -122,6 +138,8 @@ Media settings apply to the next card without a restart.
|
||||
|
||||
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
|
||||
|
||||
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
|
||||
|
||||
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
|
||||
|
||||
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
|
||||
@@ -208,6 +226,8 @@ For Senren, use the same keys under `isSenren`.
|
||||
| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
|
||||
| `manual` | Show both cards, let you choose which to keep and preview the merge |
|
||||
|
||||
Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media.
|
||||
|
||||
The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
|
||||
|
||||
| Key | Action |
|
||||
|
||||
+76
-3
@@ -1,6 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
**Changed**
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
**Fixed**
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
**Docs**
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
**Added**
|
||||
- **Hachidori Dictionary Backend**:
|
||||
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
|
||||
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
||||
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
|
||||
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
|
||||
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
|
||||
|
||||
**Fixed**
|
||||
- **Jellyfin**:
|
||||
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
|
||||
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
|
||||
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
|
||||
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
|
||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
|
||||
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
|
||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
|
||||
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
||||
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
|
||||
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
|
||||
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
|
||||
|
||||
**Docs**
|
||||
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
|
||||
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
|
||||
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
|
||||
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
|
||||
- **Dead Code Removal**:
|
||||
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
|
||||
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
|
||||
- Typecheck now runs with `noUnusedLocals`.
|
||||
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.20.x</summary>
|
||||
|
||||
<h2>v0.20.1 (2026-10-03)</h2>
|
||||
|
||||
**Added**
|
||||
- **YouTube Browser Window**:
|
||||
@@ -50,7 +123,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
<h2>v0.20.0 (2026-09-23)</h2>
|
||||
|
||||
**Added**
|
||||
- **Japanese Subtitle Generation**:
|
||||
@@ -171,7 +244,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.19.x</summary>
|
||||
|
||||
+18
-12
@@ -67,6 +67,12 @@ Everything else needs a restart.
|
||||
|
||||
## Core settings
|
||||
|
||||
### Dictionary backend
|
||||
|
||||
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
|
||||
|
||||
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
|
||||
|
||||
### Logging
|
||||
|
||||
Log files are named by date (`app-YYYY-MM-DD.log`, `launcher-...`, `mpv-...`). Log export writes a sanitized copy and leaves the originals alone.
|
||||
@@ -579,18 +585,18 @@ A local web dashboard at `http://127.0.0.1:<serverPort>`, also available as an o
|
||||
|
||||
Settings for mpv instances that SubMiner starts, and for the bundled mpv plugin. See [mpv plugin](/mpv-plugin).
|
||||
|
||||
| Key | Default | What it does |
|
||||
| ---------------------------- | ----------------- | --------------------------------------------------------------------------- |
|
||||
| `mpv.executablePath` | `""` | Path to `mpv.exe` on Windows. Empty checks `SUBMINER_MPV_PATH`, then `PATH` |
|
||||
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
|
||||
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
|
||||
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
|
||||
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
|
||||
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
|
||||
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
|
||||
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
|
||||
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
|
||||
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
|
||||
| Key | Default | What it does |
|
||||
| ---------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `mpv.executablePath` | `""` | Full path to mpv. Empty auto-discovers it ([lookup order](/jellyfin-integration#casting-from-jellyfin)) |
|
||||
| `mpv.launchMode` | `"normal"` | Window state: `normal`, `maximized`, or `fullscreen` |
|
||||
| `mpv.profile` | `""` | mpv profile to pass. Combined with a launcher `--profile` if both are set |
|
||||
| `mpv.socketPath` | platform-specific | mpv IPC socket. See the warning under [Config file](#configuration-file) |
|
||||
| `mpv.backend` | `"auto"` | Window tracking: `auto`, `hyprland`, `sway`, `x11`, `macos`, `windows` |
|
||||
| `mpv.autoStartSubMiner` | `true` | Start SubMiner in the background when mpv loads a file |
|
||||
| `mpv.pauseUntilOverlayReady` | `true` | Keep mpv paused until subtitles are ready, up to 30 seconds |
|
||||
| `mpv.subminerBinaryPath` | `""` | SubMiner app path for the plugin. Empty uses the detected path |
|
||||
| `mpv.aniskipEnabled` | `true` | Detect intros with AniSkip and show a skip prompt |
|
||||
| `mpv.aniskipButtonKey` | `"TAB"` | mpv key that skips the intro while the prompt is shown |
|
||||
|
||||
### YouTube playback settings
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ Charts for watch time, cards, words, and sessions per day or month, running tota
|
||||
|
||||
### Sessions
|
||||
|
||||
Session history with new-word activity and pause, seek, and card markers. The **↗** button on a row opens that show's detail view.
|
||||
Session history. Expand a session for its pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words over the session with Yomitan lookup ticks and clickable card-mined markers underneath. The **↗** button on a row opens that show's detail view.
|
||||
|
||||

|
||||
|
||||
@@ -91,11 +91,13 @@ Searches the primary subtitle lines and titles in your history. **Search by head
|
||||
|
||||
Search results and the Vocabulary word panel can create cards from past lines, as long as the source video file is still available:
|
||||
|
||||
- **Mine Word**: full Yomitan lookup for the word, plus sentence, audio, and image.
|
||||
- **Mine Word**: looks up the word with the selected dictionary backend (Yomitan or Hachidori), plus sentence, audio, and image. The history line you picked is the card's sentence, even while mpv plays another line. Hachidori uses its own Anki template, dictionary aliases, and frequency data.
|
||||
- **Mine Sentence**: a sentence card with `IsSentenceCard` set, for Lapis and Kiku note types.
|
||||
- **Mine Audio**: an audio card with `IsAudioCard` set.
|
||||
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs Yomitan dictionaries.
|
||||
Word and audio mining appear only when the word occurs in the sentence. All three use your `ankiConnect` deck, note type, fields, and media settings. Anki must be running, and Mine Word needs dictionaries in the selected backend.
|
||||
|
||||
Dashboard cards also get the `SubMiner::Stats` tag. With the Anki proxy off, SubMiner uses it to keep the chosen history line when polling picks up the card.
|
||||
|
||||
## Repeated lines
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ First-run setup can install the `subminer` command for you.
|
||||
xattr -d com.apple.quarantine /Applications/SubMiner.app
|
||||
```
|
||||
|
||||
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). The overlay cannot follow the mpv window without this.
|
||||
3. Open **System Settings > Privacy & Security > Accessibility** and enable SubMiner (add it if it is missing). On macOS 27 this list is named **Device Control and Data Access**. SubMiner uses it to track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
|
||||
|
||||
First-run setup can install the `subminer` command for you.
|
||||
|
||||
@@ -226,7 +226,7 @@ make deps
|
||||
make build-macos
|
||||
```
|
||||
|
||||
The `.dmg` and `.zip` land in `release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
||||
The `.dmg` and `.zip` land in `dist/release/`. For an unsigned local build, run `bun run build:mac:unsigned`.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ SubMiner selects a Japanese subtitle track automatically and resets mpv's subtit
|
||||
|
||||
If an episode has no Japanese subtitles, you can [generate them with Whisper](/subtitle-generation#generating-from-the-overlay) while it plays.
|
||||
|
||||
On Windows, casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid `mpv.executablePath` stops mpv from starting.
|
||||
Casting finds mpv through `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. On macOS it then checks `/opt/homebrew/bin/mpv`, `/usr/local/bin/mpv`, and `/Applications/mpv.app`, because an app opened from Finder, the Dock, or Spotlight does not get your shell's `PATH`. If mpv lives somewhere else, set `mpv.executablePath` to its full path (`command -v mpv` prints it). An invalid `mpv.executablePath` stops mpv from starting.
|
||||
|
||||
## Playing from the terminal
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
@@ -734,10 +741,10 @@
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"executablePath": "", // Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2.2 MiB |
+16
-16
@@ -6,12 +6,12 @@ Shortcuts work when the overlay has focus. With the [mpv plugin](/mpv-plugin), `
|
||||
|
||||
## Global
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------- | ---------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------- | ------------------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | Fixed |
|
||||
|
||||
`Alt+Shift+Y` is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
`Alt+Shift+Y` opens Yomitan or Hachidori settings, whichever backend is running. It is registered with the OS and works from any app. If another app already uses it, SubMiner cannot take it and you cannot rebind it.
|
||||
|
||||
## Mining
|
||||
|
||||
@@ -98,17 +98,17 @@ The subtitle picker (`g` then `s`) is off until you turn it on in **Settings, Be
|
||||
|
||||
Press `y`, then the second key.
|
||||
|
||||
| Keys | Action |
|
||||
| ----- | -------------------------- |
|
||||
| `y-y` | Open the SubMiner menu |
|
||||
| `y-s` | Start the overlay |
|
||||
| `y-S` | Stop the overlay |
|
||||
| `y-t` | Toggle the visible overlay |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart the overlay |
|
||||
| `y-c` | Show overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `v` | Cycle primary subtitle bar |
|
||||
| Keys | Action |
|
||||
| ----- | ------------------------------- |
|
||||
| `y-y` | Open the SubMiner menu |
|
||||
| `y-s` | Start the overlay |
|
||||
| `y-S` | Stop the overlay |
|
||||
| `y-t` | Toggle the visible overlay |
|
||||
| `y-o` | Open active dictionary settings |
|
||||
| `y-r` | Restart the overlay |
|
||||
| `y-c` | Show overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `v` | Cycle primary subtitle bar |
|
||||
|
||||
The plugin's `v` replaces mpv's own subtitle visibility toggle. When the overlay has focus, `y` then `d` toggles DevTools.
|
||||
|
||||
|
||||
@@ -39,11 +39,18 @@ SubMiner reads subtitles from mpv over an IPC socket (a named pipe on Windows).
|
||||
|
||||
SubMiner reconnects on its own if mpv restarts.
|
||||
|
||||
## Casting from Jellyfin does nothing
|
||||
|
||||
When `connect ENOENT /tmp/subminer-socket` follows a cast in the app log, SubMiner could not start mpv, so the socket never appeared. Look for `Failed handling Jellyfin remote Play event` or `Failed to launch mpv for Jellyfin remote playback` just before it.
|
||||
|
||||
- Check that `mpv --version` works in a terminal.
|
||||
- If it does, SubMiner is not finding mpv where it is installed. Set `mpv.executablePath` to the output of `command -v mpv`. See [Casting from Jellyfin](/jellyfin-integration#casting-from-jellyfin) for where SubMiner looks.
|
||||
|
||||
## Overlay does not appear
|
||||
|
||||
- Confirm SubMiner is running (`SubMiner.AppImage --start`, or check for the process).
|
||||
- Linux: Hyprland and Sway work natively. Any other compositor needs mpv and SubMiner under X11 or Xwayland, with `xdotool`, `xprop`, and `xwininfo` installed. See [KDE Plasma and other Wayland compositors](#kde-plasma-and-other-wayland-compositors).
|
||||
- macOS: grant Accessibility permission in System Settings > Privacy & Security > Accessibility.
|
||||
- macOS: grant Accessibility permission. See [macOS](#macos).
|
||||
|
||||
## Overlay is on the wrong monitor or position
|
||||
|
||||
@@ -79,7 +86,7 @@ SubMiner detected that a previously non-empty Yomitan profile suddenly appears e
|
||||
|
||||
## "Yomitan extension not found in any search path"
|
||||
|
||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`.
|
||||
The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `dist/yomitan`.
|
||||
|
||||
## "MeCab not found on system"
|
||||
|
||||
@@ -255,5 +262,5 @@ mpv --gpu-context=x11vk,x11egl,x11 video.mkv
|
||||
|
||||
## macOS
|
||||
|
||||
- Accessibility permission is required for window tracking: System Settings > Privacy & Security > Accessibility.
|
||||
- Grant Accessibility permission under System Settings > Privacy & Security > Accessibility (named **Device Control and Data Access** on macOS 27). Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized.
|
||||
- If Gatekeeper blocks the app, right-click it and choose Open, or run `xattr -d com.apple.quarantine /path/to/SubMiner.app`.
|
||||
+40
-9
@@ -16,10 +16,41 @@ Run `subminer` with no file to pick one from the current directory instead. See
|
||||
|
||||
### Yomitan setup
|
||||
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan. First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
Lookups need at least one dictionary in SubMiner's bundled Yomitan (or in Hachidori, if you [switched backends](#hachidori-setup)). First-run setup asks you to import one. To add more later, open Yomitan settings with `Alt+Shift+Y` or `subminer app --yomitan`.
|
||||
|
||||
The bundled Yomitan is separate from any Yomitan in your browser. It has its own dictionaries and settings.
|
||||
|
||||
### Hachidori setup
|
||||
|
||||
[Hachidori](https://github.com/bee-san/hachidori) is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||
|
||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
||||
|
||||
Hachidori uses SubMiner's subtitle scanning, popup pause, controller commands, character dictionaries, and Anki media. Keep the [Anki proxy](/anki-integration#proxy-mode-setup-yomitan-texthooker) on for screenshots and sentence audio. Hachidori's own screen recorder and screenshot capture are off inside SubMiner. `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to whichever backend is selected.
|
||||
|
||||
Switching backends:
|
||||
|
||||
- First-run setup asks for dictionaries the first time you switch to a backend. Switching back to a backend that already finished setup skips it.
|
||||
- Until you restart, SubMiner keeps running the backend it started with. The launcher waits for that backend before playback and logs a restart reminder.
|
||||
- `--yomitan` and `--hachidori` both work whichever backend is selected. Opening settings does not switch backends.
|
||||
- When `yomitan.externalProfilePath` is set, `--yomitan` is disabled to keep the external profile read-only. Hachidori settings still open.
|
||||
|
||||
#### External dictionary host
|
||||
|
||||
First-run setup can link a Hachidori host instead of using local dictionaries: **Dictionary source → Use an external dictionary host → Link host**. Turn on sharing in the other Hachidori app or browser, or start a compatible Docker host, then enter its sharing address, for example `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port.
|
||||
|
||||
| Host | Must be running |
|
||||
| -------- | ------------------------------------------- |
|
||||
| Browser | The browser, the Hachidori extension, relay |
|
||||
| Electron | The host app and any relay it needs |
|
||||
| Docker | The container only |
|
||||
|
||||
Setup checks the connection and the host's dictionaries before **Finish** unlocks, so import at least one dictionary on the host and refresh. The link survives restarts. **Unlink and use local dictionaries** goes back to local.
|
||||
|
||||
While linked, dictionaries and dictionary settings come from the host. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio and image processing stay local. Frequency annotations use ranks returned with dictionary entries, and SubMiner asks the host for missing ones. Words with no matching definition entry may stay unranked even if a frequency dictionary lists them.
|
||||
|
||||
SubMiner sends [character dictionaries](/character-dictionary) to the linked host over the link and replaces the previous one once the import succeeds, retrying while the host is busy. The host needs Hachidori 0.2.3 or later, or a current Docker host. With an older host, sync reports where the merged ZIP is so you can import it from that host's Hachidori settings.
|
||||
|
||||
## Picking files
|
||||
|
||||
```bash
|
||||
@@ -33,15 +64,15 @@ See [Launcher script](/launcher-script#video-picker) for picker and history deta
|
||||
|
||||
## Overlay basics
|
||||
|
||||
| Key | Action |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings (works from any window, not configurable) |
|
||||
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
|
||||
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
|
||||
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
|
||||
| Key | Action |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Show or hide the overlay (works while the overlay or mpv has focus) |
|
||||
| `Alt+Shift+Y` | Open Yomitan or Hachidori settings (works from any window, not configurable) |
|
||||
| `V` | Cycle the subtitle bar through hidden, visible, and hover-only |
|
||||
| `Ctrl+Alt+P` | Open the playlist browser to queue, reorder, or jump between episodes |
|
||||
| `Ctrl/Cmd+/` | Show every overlay and mpv keybinding for this session |
|
||||
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open Yomitan popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
Hovering subtitle text pauses mpv, and moving away resumes it. An open dictionary popup also keeps playback paused. Turn these off with `subtitleStyle.autoPauseVideoOnHover` and `subtitleStyle.autoPauseVideoOnYomitanPopup`.
|
||||
|
||||
You can drop files onto the overlay:
|
||||
|
||||
|
||||
+10
-10
@@ -54,10 +54,10 @@ files before signing and generating updater hashes, never from a signed app.
|
||||
4. Bump `package.json` to the release version.
|
||||
5. Build release metadata before tagging (this calls `claude -p` locally):
|
||||
`bun run changelog:build --version <version> --date <yyyy-mm-dd>`
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed
|
||||
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed
|
||||
before tagging. Release CI no longer auto-builds the changelog; it fails
|
||||
fast if `changes/*.md` fragments are still present on a tag-based run.
|
||||
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
|
||||
6. Review `CHANGELOG.md` and `packaging/release-notes/release-notes.md`. Edit by hand if Claude
|
||||
missed something — the committed Markdown is what ships.
|
||||
7. Run release gate locally:
|
||||
`bun run changelog:check --version <version>`
|
||||
@@ -73,9 +73,9 @@ files before signing and generating updater hashes, never from a signed app.
|
||||
`bun-v<version>-source.tar.gz` plus its `.sha256` file. The script
|
||||
fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch
|
||||
inputs, or collected license files differ from
|
||||
`build/bun-source-manifest.json`.
|
||||
`packaging/bun/bun-source-manifest.json`.
|
||||
When validating auto-update metadata, also run the relevant platform package
|
||||
build and confirm `release/` contains the generated updater metadata
|
||||
build and confirm `dist/release/` contains the generated updater metadata
|
||||
(`latest*.yml`) and blockmaps (`*.blockmap`).
|
||||
8. If `docs-site/` changed, also run:
|
||||
`bun run docs:test`
|
||||
@@ -103,9 +103,9 @@ files before signing and generating updater hashes, never from a signed app.
|
||||
source archive. A missing source repository or license file fails the
|
||||
release instead of publishing only the executable.
|
||||
When validating packaged updater output, confirm the platform build writes
|
||||
`latest*.yml` and `*.blockmap` files under `release/`.
|
||||
`latest*.yml` and `*.blockmap` files under `dist/release/`.
|
||||
5. Commit the prerelease prep (package.json version bump + the generated
|
||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||
`packaging/release-notes/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||
committed file — so review it before committing. Rerun
|
||||
`bun run changelog:prerelease-notes --version <version>` for every later
|
||||
beta/RC, even if no fragments changed: the notes carry a hidden
|
||||
@@ -128,14 +128,14 @@ Notes:
|
||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `packaging/release-notes/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
|
||||
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `packaging/release-notes/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `packaging/release-notes/release-notes.md` (and `packaging/release-notes/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
||||
|
||||
@@ -46,6 +46,12 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, keeps the lookup highlight on in the overlay first-install options (SubMiner captures media from mpv, not the overlay viewport), removes the unsupported `userScripts` permission, and sets a fixed manifest `key` so the extension ID (and the storage origin holding its dictionaries and settings) does not depend on the userData path, only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Dictionary imports go through Hachidori's chunked upload requests (`hachidori-linked-import`): an unlinked install imports the ZIP itself and a linked one forwards it to its host. Linked hosts that do not advertise `linked-import-v1` (Hachidori before 0.2.3) cannot receive uploads, and the import reports where to import the ZIP by hand. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. Attention also covers a left press anywhere on the overlay that may start a selection, and the host element only exists after the first lookup, so once a Hachidori event has been seen, popup auto-pause, popup key routing, controller lookup state, and the gate on imported mpv keys and mouse buttons require an unhidden popup pane in the host's shadow root. Auto-pause rechecks after each successful lookup. Hachidori's popup lives in the overlay document rather than an iframe, so wheel events over its host scroll the popup instead of forwarding to mpv. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
|
||||
@@ -62,10 +62,11 @@ bun run docs:build
|
||||
then `bun run test:e2e` (see [Headless End-to-End Harness](#headless-end-to-end-harness))
|
||||
- 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`.
|
||||
On headless Linux: `xvfb-run -a bun run test:package dist/release/linux-unpacked/resources`.
|
||||
Content checks run inside the electron-builder afterPack hook. See the
|
||||
[release guide](../RELEASING.md#package-contents-checks) for the
|
||||
installed-app verification checklist.
|
||||
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux (CI runs it in the Linux E2E job). It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
|
||||
// so the app can resolve their fields; scenarios still assert on `requests`.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
@@ -59,10 +61,38 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'addNote': {
|
||||
const noteId = nextNoteId++;
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
|
||||
return noteId;
|
||||
}
|
||||
case 'updateNoteFields': {
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
|
||||
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
|
||||
return null;
|
||||
}
|
||||
case 'notesInfo': {
|
||||
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
|
||||
const ids = Array.isArray(params.notes) ? params.notes : [];
|
||||
return ids.map((id) => {
|
||||
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
|
||||
if (!fields) return {};
|
||||
return {
|
||||
noteId: id,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
tags: [],
|
||||
fields: Object.fromEntries(
|
||||
MODEL_FIELDS.map((name, order) => [
|
||||
name,
|
||||
{ value: String(fields[name] ?? ''), order },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
|
||||
@@ -91,6 +91,14 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
||||
});
|
||||
}
|
||||
|
||||
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||
function findMediaUpdate(session: E2eSession) {
|
||||
return session.anki.requests.find(
|
||||
(request) =>
|
||||
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||
);
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
@@ -126,10 +134,10 @@ test(
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
@@ -156,6 +164,38 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a line retimed with sub-delay clips it where mpv plays it',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
// MiscInfo stamps the clip start: the cue's start on the video clock.
|
||||
const written = JSON.stringify(session.anki.requests);
|
||||
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -184,6 +224,49 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subtitle sidebar follows and seeks a sub-delay retimed track',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
|
||||
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
|
||||
{ description: 'sidebar to highlight the delayed line' },
|
||||
);
|
||||
|
||||
await overlay.evaluate(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
|
||||
);
|
||||
const target = FIXTURE_CUES[2].start + delay;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const time = Number(await session.mpv.command('get_property', 'time-pos'));
|
||||
return time >= target && time < target + 0.5;
|
||||
},
|
||||
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
|
||||
);
|
||||
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||
{ description: 'sidebar to close' },
|
||||
);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
import {
|
||||
hasLauncherExternalYomitanProfileConfig,
|
||||
loadLauncherDictionaryBackend,
|
||||
} from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: loadLauncherDictionaryBackend(),
|
||||
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
|
||||
warn: (message) => log('warn', args.logLevel, message),
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
hasLegacyMpvPlugin: () =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from './log.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import type {
|
||||
Args,
|
||||
LauncherLoggingConfig,
|
||||
@@ -100,6 +101,12 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLauncherDictionaryBackend(): DictionaryBackend {
|
||||
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
|
||||
? 'hachidori'
|
||||
: 'yomitan';
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,10 @@ export function applyRootOptionsToArgs(
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.yomitan === true || options.hachidori === true) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
|
||||
}
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
|
||||
@@ -87,6 +87,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('--yomitan', 'Open Yomitan settings window')
|
||||
.option('--hachidori', 'Open Hachidori settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
|
||||
@@ -378,6 +378,11 @@ const forwardedAppArgsCases: Array<{
|
||||
argv: () => ['settings'],
|
||||
expected: () => '--settings\n',
|
||||
},
|
||||
...['--settings', '--yomitan', '--hachidori'].map((flag) => ({
|
||||
name: `launcher ${flag} forwards the matching app settings command`,
|
||||
argv: () => [flag],
|
||||
expected: () => `${flag}\n`,
|
||||
})),
|
||||
{
|
||||
name: 'launcher youtube command forwards the YouTube browser flag and log level',
|
||||
argv: () => ['yt', '--log-level', 'debug'],
|
||||
@@ -562,3 +567,43 @@ test(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('external Yomitan profile remains available while a running app awaits a backend switch', () => {
|
||||
withSandbox(({ env, writeConfigFile }) => {
|
||||
writeConfigFile('config.jsonc', {
|
||||
dictionaryBackend: 'hachidori',
|
||||
yomitan: { externalProfilePath: '/external/yomitan-profile' },
|
||||
});
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--eval',
|
||||
`
|
||||
import assert from 'node:assert/strict';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from './launcher/config.ts';
|
||||
import { ensureLauncherSetupReady } from './launcher/setup-gate.ts';
|
||||
import { createDefaultSetupState } from './src/shared/setup-state.ts';
|
||||
for (const running of [true, false]) {
|
||||
let launches = 0;
|
||||
let tick = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => running,
|
||||
readSetupState: () => ({ ...createDefaultSetupState(), dictionaryBackend: 'yomitan' }),
|
||||
isExternalYomitanConfigured: hasLauncherExternalYomitanProfileConfig,
|
||||
launchSetupApp: () => { launches += 1; },
|
||||
sleep: async () => {},
|
||||
now: () => tick++,
|
||||
timeoutMs: 2,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, running);
|
||||
assert.equal(launches, running ? 0 : 1);
|
||||
}
|
||||
`,
|
||||
],
|
||||
{ cwd: process.cwd(), env, encoding: 'utf8', timeout: LAUNCHER_RUN_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
@@ -174,8 +174,9 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
||||
|
||||
test(
|
||||
'runAppCommandCaptureOutput transports Linux AppImage args through environment',
|
||||
{ skip: process.platform !== 'linux' },
|
||||
async () => {
|
||||
// Bun's node:test shim ignores the `skip` option, so gate the platform here.
|
||||
if (process.platform !== 'linux') return;
|
||||
await withTempCase(({ dir }) => {
|
||||
const appPath = writeFakeApp(
|
||||
dir,
|
||||
|
||||
@@ -339,3 +339,12 @@ test('parseArgs requires an explicit logs action', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Logs command requires -e or --export/);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`parseArgs forwards ${flag} to the app`, () => {
|
||||
const parsed = parseArgs([flag], 'subminer', {});
|
||||
assert.equal(parsed.appPassthrough, true);
|
||||
assert.deepEqual(parsed.appArgs, [flag]);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
}
|
||||
+104
-2
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
import {
|
||||
ensureLauncherSetupReady,
|
||||
resolveLauncherGateBackend,
|
||||
waitForSetupCompletion,
|
||||
} from './setup-gate';
|
||||
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
|
||||
|
||||
/** An in-progress setup state; tests override only the fields that matter. */
|
||||
function makeState(overrides: Partial<SetupState> = {}): SetupState {
|
||||
@@ -189,3 +193,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
|
||||
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
|
||||
let launched = 0;
|
||||
let polls = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => state,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
launched += 1;
|
||||
},
|
||||
sleep: async () => {
|
||||
polls += 1;
|
||||
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
|
||||
},
|
||||
now: () => polls,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
assert.equal(launched, 1);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('matching Hachidori completion resumes playback without launching setup', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'completed',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('a backend that finished setup earlier passes the gate after switching back', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'yomitan',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'incomplete',
|
||||
completedDictionaryBackends: ['yomitan'],
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gate follows the running app backend until it restarts into the configured one', async () => {
|
||||
const warnings: string[] = [];
|
||||
const state = {
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'yomitan' as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
'yomitan',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => false,
|
||||
}),
|
||||
'hachidori',
|
||||
);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => true,
|
||||
readSetupState: () => state,
|
||||
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
+47
-6
@@ -1,7 +1,13 @@
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import {
|
||||
getSetupStateDictionaryBackend,
|
||||
isSetupCompleted,
|
||||
type SetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
if (isSetupCompleted(state, deps.dictionaryBackend)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
isSetupCompleted(state, deps.dictionaryBackend) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
/**
|
||||
* The app pins its dictionary backend at startup while the config file can change
|
||||
* underneath it. When an app is already running, gate on the backend it recorded
|
||||
* in the setup state rather than the config value it has not restarted into.
|
||||
*/
|
||||
export async function resolveLauncherGateBackend(deps: {
|
||||
configuredBackend: DictionaryBackend;
|
||||
state: SetupState | null;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<DictionaryBackend> {
|
||||
const runningBackend = deps.state
|
||||
? getSetupStateDictionaryBackend(deps.state)
|
||||
: deps.configuredBackend;
|
||||
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
|
||||
return deps.configuredBackend;
|
||||
}
|
||||
deps.warn?.(
|
||||
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
|
||||
);
|
||||
return runningBackend;
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
const dictionaryBackend = await resolveLauncherGateBackend({
|
||||
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
|
||||
state: initialState,
|
||||
isAppRunning: deps.isAppRunning,
|
||||
warn: deps.warn,
|
||||
});
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
dictionaryBackend,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
dictionaryBackend,
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
|
||||
+25
-11
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.20.1",
|
||||
"version": "0.21.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -12,6 +12,7 @@
|
||||
"get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test:dictionary:electron": "bun scripts/run-dictionary-settings-smoke.mjs && bun scripts/run-dictionary-settings-smoke.mjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||
@@ -21,7 +22,7 @@
|
||||
"build:launcher": "bun run scripts/build-launcher.ts",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
@@ -83,7 +84,8 @@
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
|
||||
"test:package": "bun scripts/run-package-smoke.mjs"
|
||||
"test:package": "bun scripts/run-package-smoke.mjs",
|
||||
"build:hachidori": "bun scripts/build-hachidori.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
@@ -91,6 +93,7 @@
|
||||
"brace-expansion": "5.0.11",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.7",
|
||||
"follow-redirects": "1.16.1",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.2",
|
||||
@@ -99,7 +102,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.1"
|
||||
"undici": "7.30.1"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -128,12 +131,12 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "43.7.7",
|
||||
"electron": "43.8.3",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.1"
|
||||
"undici": "7.30.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.sudacode.SubMiner",
|
||||
@@ -143,7 +146,8 @@
|
||||
"afterPack": "scripts/electron-builder-after-pack.cjs",
|
||||
"icon": "assets/SubMiner-square.png",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
"buildResources": "packaging/electron-builder",
|
||||
"output": "dist/release"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
@@ -175,8 +179,8 @@
|
||||
"category": "public.app-category.video",
|
||||
"icon": "assets/SubMiner-square.png",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"entitlements": "packaging/electron-builder/entitlements.mac.plist",
|
||||
"entitlementsInherit": "packaging/electron-builder/entitlements.mac.plist",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
@@ -208,7 +212,7 @@
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "build/installer.nsh"
|
||||
"include": "packaging/electron-builder/installer.nsh"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
@@ -222,15 +226,20 @@
|
||||
"!**/{test,tests,__tests__,fixture,fixtures,__fixtures__}{,/**/*}",
|
||||
"!dist/launcher{,/**/*}",
|
||||
"!dist/scripts{,/**/*}",
|
||||
"!dist/{yomitan,hachidori,release}{,/**/*}",
|
||||
"!dist/{renderer,settings,syncui}/fonts{,/**/*}",
|
||||
"!node_modules/koffi/{src,vendor,doc}{,/**/*}",
|
||||
"!node_modules/@libsql/linux-x64-musl{,/**/*}"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/yomitan",
|
||||
"from": "dist/yomitan",
|
||||
"to": "yomitan"
|
||||
},
|
||||
{
|
||||
"from": "dist/hachidori",
|
||||
"to": "hachidori"
|
||||
},
|
||||
{
|
||||
"from": "vendor/yomitan-jlpt-vocab",
|
||||
"to": "yomitan-jlpt-vocab"
|
||||
@@ -270,6 +279,11 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
}
|
||||
},
|
||||
"licenseInventoryStatus": "source-and-notices-pinned-to-binary-revision",
|
||||
"sourceManifest": "build/bun-source-manifest.json",
|
||||
"sourceManifest": "packaging/bun/bun-source-manifest.json",
|
||||
"correspondingSourceAsset": "bun-v1.3.5-source.tar.gz"
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -10,7 +10,7 @@ The SubMiner GitHub release containing this application also contains `bun-v1.3.
|
||||
- every external source repository registered by that Bun revision's CMake build, at its exact commit
|
||||
- Bun's dependency patches, build scripts, lockfiles, collected third-party license files, and instructions for rebuilding Bun against a modified JavaScriptCore
|
||||
|
||||
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `build/bun-source-manifest.json` in SubMiner's source repository.
|
||||
The machine-readable inventory lives at `SOURCE-INVENTORY.json` inside the source archive and at `packaging/bun/bun-source-manifest.json` in SubMiner's source repository.
|
||||
|
||||
The archive vendors the source repositories linked through Bun's CMake build. It preserves Bun's package-manager lockfiles and lol-html's `Cargo.lock`, but it does not vendor npm packages, crates.io packages, compilers, SDKs, or other build tools. Rebuilding needs network access for those package-manager and toolchain inputs. The archive's rebuild README records the known tool versions and the remaining unpinned Rust nightly input.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Bun 1.3.5 third-party notices
|
||||
|
||||
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in build/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
||||
This file collects the license texts shipped by the external source repositories used by Bun 1.3.5 at the revisions in packaging/bun/bun-source-manifest.json. The corresponding-source release asset also preserves license notices embedded in individual Bun and WebKit source files. Bun’s own LICENSE.md lists additional embedded libraries and JavaScript polyfills whose source and per-file notices are included in that asset.
|
||||
|
||||
## BoringSSL
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,32 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
- **Stats Session View**: The expanded session view in the stats dashboard has been redesigned. It shows stat tiles for pace, lines, known-word rate, lookups, and paused time, plus a reading density chart of known and unknown words with lookup and card-mined markers.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Delay**: Retiming subtitles in mpv (z/x or sub-step) now carries through SubMiner. Mined card audio, images, and timestamps match the retimed line, and so do the subtitle sidebar, subtitle navigation, and replay.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse buttons, such as volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(overlay): forward mpv input while subtitle sidebar is open by @ksyasuda in #300
|
||||
- fix(subtitles): follow mpv sub-delay in mining, sidebar, and navigation by @ksyasuda in #302
|
||||
- feat(stats): redesign expanded session view with density chart by @ksyasuda in #304
|
||||
|
||||
## 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.
|
||||
@@ -203,7 +203,7 @@ test('writeChangelogArtifacts ignores README, groups fragments by type, writes r
|
||||
assert.match(changelog, /### Fixed\n- Polished: fixed entry\./);
|
||||
assert.match(changelog, /## v0\.4\.0 \(2026-03-01\)\n- Existing fix\n$/);
|
||||
|
||||
const releaseNotes = project.read('release/release-notes.md');
|
||||
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||
assert.match(releaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||
assert.match(releaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||
@@ -240,7 +240,7 @@ test('writeChangelogArtifacts skips changelog prepend when release section alrea
|
||||
assert.equal(project.exists('changes/001.md'), false);
|
||||
assert.equal(project.read('CHANGELOG.md'), existingChangelog);
|
||||
assert.match(
|
||||
project.read('release/release-notes.md'),
|
||||
project.read('packaging/release-notes/release-notes.md'),
|
||||
/## Highlights\n### Added\n- Existing release bullet\./,
|
||||
);
|
||||
},
|
||||
@@ -268,7 +268,7 @@ test('writeStableReleaseArtifacts reuses the requested version and date for chan
|
||||
});
|
||||
|
||||
assert.deepEqual(result.outputPaths, [project.at('CHANGELOG.md')]);
|
||||
assert.equal(result.releaseNotesPath, project.at('release/release-notes.md'));
|
||||
assert.equal(result.releaseNotesPath, project.at('packaging/release-notes/release-notes.md'));
|
||||
assert.equal(result.docsChangelogPath, project.at('docs-site/changelog.md'));
|
||||
|
||||
assert.match(project.read('CHANGELOG.md'), /## v0\.4\.1 \(2026-03-07\)/);
|
||||
@@ -471,7 +471,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
||||
deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
|
||||
});
|
||||
|
||||
assert.equal(outputPath, project.at('release/prerelease-notes.md'));
|
||||
assert.equal(outputPath, project.at('packaging/release-notes/prerelease-notes.md'));
|
||||
assert.equal(
|
||||
project.read('CHANGELOG.md'),
|
||||
existingChangelog,
|
||||
@@ -527,7 +527,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
||||
'prerelease-reuse-existing-notes',
|
||||
{
|
||||
'package.json': packageJson('0.11.3-beta.2'),
|
||||
'release/prerelease-notes.md': existingNotes,
|
||||
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||
'changes/001.md': fragment('fixed', 'launcher', '- Fixed launcher prerelease packaging.'),
|
||||
},
|
||||
(project) => {
|
||||
@@ -574,7 +574,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
|
||||
'prerelease-ignore-unmarked-old-notes',
|
||||
{
|
||||
'package.json': packageJson('0.17.0-beta.1'),
|
||||
'release/prerelease-notes.md': existingNotes,
|
||||
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||
'changes/001.md': fragment(
|
||||
'changed',
|
||||
'overlay',
|
||||
@@ -785,7 +785,7 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
||||
'release-notes mode drops internal fragments',
|
||||
);
|
||||
|
||||
const releaseNotes = project.read('release/release-notes.md');
|
||||
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||
assert.match(
|
||||
@@ -841,7 +841,7 @@ test('writeChangelogArtifacts appends contributor attribution and a new-contribu
|
||||
project.at('changes/002.md'),
|
||||
]);
|
||||
|
||||
const releaseNotes = project.read('release/release-notes.md');
|
||||
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||
assert.match(releaseNotes, /## What's Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
@@ -904,7 +904,10 @@ test('writeChangelogArtifacts skips contributor attribution in GitHub Actions wi
|
||||
});
|
||||
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.doesNotMatch(project.read('release/release-notes.md'), /## What's Changed/);
|
||||
assert.doesNotMatch(
|
||||
project.read('packaging/release-notes/release-notes.md'),
|
||||
/## What's Changed/,
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
for (const key of envKeys) {
|
||||
@@ -990,7 +993,10 @@ test('writeReleaseNotesForVersion preserves committed contributor attribution be
|
||||
|
||||
await withProject(
|
||||
'release-notes-preserve-attribution',
|
||||
{ 'CHANGELOG.md': existingChangelog, 'release/release-notes.md': committedReleaseNotes },
|
||||
{
|
||||
'CHANGELOG.md': existingChangelog,
|
||||
'packaging/release-notes/release-notes.md': committedReleaseNotes,
|
||||
},
|
||||
(project) => {
|
||||
const outputPath = writeReleaseNotesForVersion({
|
||||
cwd: project.projectRoot,
|
||||
@@ -1055,7 +1061,7 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
|
||||
'no Claude calls should fire when the section already exists',
|
||||
);
|
||||
|
||||
const releaseNotes = project.read('release/release-notes.md');
|
||||
const releaseNotes = project.read('packaging/release-notes/release-notes.md');
|
||||
assert.match(releaseNotes, /## Highlights\n### Added\n- Polished: previously committed\./);
|
||||
assert.doesNotMatch(releaseNotes, /<details>/);
|
||||
assert.doesNotMatch(releaseNotes, /### Internal/);
|
||||
@@ -1237,7 +1243,7 @@ test('writePrereleaseNotesForVersion strips the stale delta section from the reu
|
||||
'prerelease-reuse-strips-delta',
|
||||
{
|
||||
'package.json': packageJson('0.12.0-beta.3'),
|
||||
'release/prerelease-notes.md': existingNotes,
|
||||
'packaging/release-notes/prerelease-notes.md': existingNotes,
|
||||
'changes/001.md': fragment('added', 'overlay', '- Added overlay coverage.'),
|
||||
},
|
||||
(project) => {
|
||||
@@ -1271,7 +1277,7 @@ test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale
|
||||
(project) => {
|
||||
const verify = (version = '0.12.0-beta.2') =>
|
||||
verifyPrereleaseNotesMatchVersion({ cwd: project.projectRoot, version });
|
||||
const notesPath = 'release/prerelease-notes.md';
|
||||
const notesPath = 'packaging/release-notes/prerelease-notes.md';
|
||||
|
||||
assert.throws(() => verify(), /Missing .*prerelease-notes\.md/);
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ type PullRequestChangelogOptions = {
|
||||
changedLabels?: string[];
|
||||
};
|
||||
|
||||
const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md');
|
||||
const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md');
|
||||
const RELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'release-notes.md');
|
||||
const PRERELEASE_NOTES_PATH = path.join('packaging', 'release-notes', 'prerelease-notes.md');
|
||||
const CHANGELOG_HEADER = '# Changelog';
|
||||
const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal'];
|
||||
const SKIP_CHANGELOG_LABEL = 'skip-changelog';
|
||||
@@ -1144,7 +1144,7 @@ function isFragmentPath(candidate: string): boolean {
|
||||
function isIgnoredPullRequestPath(candidate: string): boolean {
|
||||
return (
|
||||
candidate === 'CHANGELOG.md' ||
|
||||
candidate === 'release/release-notes.md' ||
|
||||
candidate === 'packaging/release-notes/release-notes.md' ||
|
||||
candidate === 'AGENTS.md' ||
|
||||
candidate === 'README.md' ||
|
||||
candidate.startsWith('changes/') ||
|
||||
@@ -1405,7 +1405,7 @@ export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): v
|
||||
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
|
||||
if (markerVersion !== version) {
|
||||
throw new Error(
|
||||
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
||||
`packaging/release-notes/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 'dist', 'hachidori');
|
||||
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
||||
|
||||
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
||||
// so ordinary app builds need neither Emscripten nor network access.
|
||||
for (const [file, expected] of Object.entries(provenance.artifacts)) {
|
||||
const actual = createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(source, file)))
|
||||
.digest('hex');
|
||||
if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`);
|
||||
}
|
||||
const extension = path.join(source, 'extension');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8'));
|
||||
for (const file of [
|
||||
manifest.options_page,
|
||||
manifest.background.service_worker,
|
||||
'offscreen.html',
|
||||
...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]),
|
||||
]) {
|
||||
if (!fs.existsSync(path.join(extension, file)))
|
||||
throw new Error(`Missing Hachidori asset: ${file}`);
|
||||
}
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.cpSync(extension, output, { recursive: true });
|
||||
// Host configuration belongs in the staged copy, leaving the fork usable in Chrome.
|
||||
const hostConfiguration = {
|
||||
'overlay-mode.js': [
|
||||
['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'],
|
||||
['customJavaScript: true,', 'customJavaScript: false,'],
|
||||
],
|
||||
// Overlay hosts seed the lookup highlight off because their Anki screenshot is
|
||||
// the see-through viewport. SubMiner captures media from mpv, and without the
|
||||
// highlight the sidebar shows nothing for the word being looked up.
|
||||
'setup-state.js': [
|
||||
[
|
||||
'lookupMode: "hover",\n sourceHighlightEnabled: false,',
|
||||
'lookupMode: "hover",\n sourceHighlightEnabled: true,',
|
||||
],
|
||||
],
|
||||
};
|
||||
for (const [file, replacements] of Object.entries(hostConfiguration)) {
|
||||
const filePath = path.join(output, file);
|
||||
let text = fs.readFileSync(filePath, 'utf8');
|
||||
for (const [original, replacement] of replacements) {
|
||||
if (!text.includes(original))
|
||||
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
|
||||
text = text.replace(original, replacement);
|
||||
}
|
||||
fs.writeFileSync(filePath, text);
|
||||
}
|
||||
manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts');
|
||||
// A fixed key pins the extension ID, so Hachidori's storage origin (dictionaries,
|
||||
// settings, setup state) survives userData path changes. Electron otherwise
|
||||
// derives the ID from the unpacked directory path.
|
||||
manifest.key =
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA40zTTbhCbrKnDOqj17tosDChP60VXuuMjR0tFFR/kAet21jrYn0JKPzqqNpXMGaJsx1chnuMOgaP0zuOT+gv9DNVV83wGLhQL7cC4LCoGN648WqnE2jYqBPj6hdD5A/N3uPeKFRAHkiC9HKoj2Et6sYZFTqag79QqQkkUz17X2VpjuTGmTP3lvhyEwhJWQKzOU0k6dTn6fKB2ZSqTebpw/JS2G8jf58vHlQOcfLhIiolKCG9DfMuo11aoG1s3AsFmQmw9VVyp4wgyQ80UI4lyBdqboanJxEiZ211+HKWPEgtG6VML1+Uo59qWS3uBU1apxE1+7E51UBEHaTsxajFIQIDAQAB';
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
|
||||
fs.copyFileSync(path.join(source, file), path.join(output, file));
|
||||
}
|
||||
process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`);
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
test('Hachidori staging configures Electron without changing the fork source', async () => {
|
||||
const files = ['overlay-mode.js', 'setup-state.js', 'manifest.json'];
|
||||
const source = (file: string) =>
|
||||
new URL(`../vendor/hachidori/extension/${file}`, import.meta.url);
|
||||
const before = files.map((file) => readFileSync(source(file), 'utf8'));
|
||||
|
||||
execFileSync(process.execPath, [
|
||||
fileURLToPath(new URL('./build-hachidori.mjs', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => readFileSync(source(file), 'utf8')),
|
||||
before,
|
||||
);
|
||||
const staged = await import(new URL('../dist/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('../dist/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('../dist/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');
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const repoRoot = path.resolve(dirname, '..');
|
||||
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
||||
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
||||
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
||||
const buildOutputDir = path.join(repoRoot, 'build', 'yomitan');
|
||||
const buildOutputDir = path.join(repoRoot, 'dist', 'yomitan');
|
||||
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
||||
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
||||
const bunCommand = process.versions.bun ? process.execPath : 'bun';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Exercises the real entry point with disposable Linux config and data directories.
|
||||
const { app, BrowserWindow, session, shell } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error('This app-entry smoke requires Linux XDG isolation.');
|
||||
const root = process.cwd();
|
||||
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
|
||||
const externalYomitanProfile =
|
||||
backend === 'hachidori' && !process.argv.includes('--bundled-yomitan');
|
||||
const profile = process.env.SUBMINER_DICTIONARY_SMOKE_DATA;
|
||||
assert(
|
||||
profile && fs.existsSync(profile),
|
||||
'Use bun run test:dictionary:electron for profile isolation',
|
||||
);
|
||||
process.env.XDG_CONFIG_HOME = profile;
|
||||
process.env.XDG_DATA_HOME = path.join(profile, 'data');
|
||||
fs.mkdirSync(path.join(profile, 'SubMiner'));
|
||||
fs.writeFileSync(
|
||||
path.join(profile, 'SubMiner', 'config.json'),
|
||||
JSON.stringify({
|
||||
dictionaryBackend: backend,
|
||||
// External Yomitan profiles must not block Hachidori settings.
|
||||
yomitan: {
|
||||
externalProfilePath: externalYomitanProfile ? path.join(profile, 'external-yomitan') : '',
|
||||
},
|
||||
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
|
||||
ankiConnect: { enabled: false },
|
||||
startupWarmups: { lowPowerMode: true },
|
||||
discordPresence: { enabled: false },
|
||||
updates: { enabled: false },
|
||||
}),
|
||||
);
|
||||
const openedLinks = [];
|
||||
shell.openExternal = async (url) => {
|
||||
openedLinks.push(url);
|
||||
};
|
||||
app.setAppPath(root);
|
||||
app.getVersion = () => require(path.join(root, 'package.json')).version;
|
||||
process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log');
|
||||
app.commandLine.appendSwitch('ozone-platform', 'x11');
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug'];
|
||||
require(path.join(root, 'dist/main-entry.js'));
|
||||
const deadline = setTimeout(() => {
|
||||
console.error(
|
||||
'FAIL timeout',
|
||||
BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
|
||||
);
|
||||
finish(1);
|
||||
}, 60000);
|
||||
function finish(exitCode) {
|
||||
clearTimeout(deadline);
|
||||
app.exit(exitCode);
|
||||
}
|
||||
(async () => {
|
||||
await app.whenReady();
|
||||
let window;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
window = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Hachidori') && w.isVisible(),
|
||||
);
|
||||
if (window) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(window, 'Hachidori settings window opens from actual app flag');
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
assert.match(window.webContents.getURL(), /settings.html/);
|
||||
const status = await window.webContents.executeJavaScript(
|
||||
`chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`,
|
||||
);
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.ready, true);
|
||||
|
||||
console.log(
|
||||
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
|
||||
);
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
|
||||
if (externalYomitanProfile) {
|
||||
const blockedMessage =
|
||||
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage)) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(
|
||||
fs.readFileSync(process.env.SUBMINER_APP_LOG, 'utf8').includes(blockedMessage),
|
||||
'--yomitan is rejected when an external Yomitan profile is configured',
|
||||
);
|
||||
assert.equal(session.defaultSession.extensions.getAllExtensions().length, 0);
|
||||
assert.ok(!BrowserWindow.getAllWindows().some((w) => w.getTitle().includes('Yomitan')));
|
||||
console.log('PASS external Yomitan settings blocked while Hachidori settings remain available');
|
||||
} else {
|
||||
let yomi;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
yomi = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
|
||||
);
|
||||
if (yomi) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(yomi, 'inactive --yomitan settings opens');
|
||||
assert.equal(yomi.webContents.session, session.defaultSession);
|
||||
}
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
|
||||
let overlay;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
overlay = BrowserWindow.getAllWindows().find((w) =>
|
||||
w.webContents.getURL().includes('/renderer/index.html?'),
|
||||
);
|
||||
if (overlay && !overlay.webContents.isLoading()) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(overlay, 'actual overlay initialized');
|
||||
assert.equal(
|
||||
overlay.webContents.session,
|
||||
backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession,
|
||||
);
|
||||
const requestLink = (url) =>
|
||||
overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000);
|
||||
window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true});
|
||||
window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}}));
|
||||
})`);
|
||||
assert.equal((await requestLink('file:///tmp/private')).ok, false);
|
||||
assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori');
|
||||
assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []);
|
||||
console.log(
|
||||
`PASS ${backend} overlay session, independent settings windows, real preload external link bridge`,
|
||||
);
|
||||
|
||||
finish(0);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
finish(1);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
// Run after bun run build. This uses a temporary profile and small local ZIPs.
|
||||
const { app, BrowserWindow, protocol, session } = require('electron');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
||||
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
bypassCSP: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
app.setPath('userData', profile);
|
||||
app.setAppPath(root);
|
||||
app.disableHardwareAcceleration();
|
||||
app.on('window-all-closed', () => {});
|
||||
const deadline = setTimeout(() => {
|
||||
console.error('Hachidori parser verification timed out', profile);
|
||||
app.exit(1);
|
||||
}, 120_000);
|
||||
|
||||
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
|
||||
const zipPath = path.join(profile, name + '.zip');
|
||||
writeStoredZip(zipPath, [
|
||||
{
|
||||
name: 'index.json',
|
||||
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
|
||||
},
|
||||
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
|
||||
]);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
const targetSession = session.fromPartition('persist:hachidori-check');
|
||||
const extension = await targetSession.extensions.loadExtension(
|
||||
path.join(root, 'dist/hachidori'),
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
// Keep a host window alive while the dictionary importer opens/closes its
|
||||
// temporary settings windows, matching the running app's window lifecycle.
|
||||
const host = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
|
||||
let parserWindow = null;
|
||||
let readyPromise = null;
|
||||
let initPromise = null;
|
||||
const deps = {
|
||||
getYomitanExt: () => extension,
|
||||
getYomitanSession: () => targetSession,
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
setYomitanParserWindow: (value) => {
|
||||
parserWindow = value;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => readyPromise,
|
||||
setYomitanParserReadyPromise: (value) => {
|
||||
readyPromise = value;
|
||||
},
|
||||
getYomitanParserInitPromise: () => initPromise,
|
||||
setYomitanParserInitPromise: (value) => {
|
||||
initPromise = value;
|
||||
},
|
||||
};
|
||||
const errors = [];
|
||||
const logger = {
|
||||
error: (...args) => {
|
||||
errors.push(args);
|
||||
console.error(...args);
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Exercise discovery and revisioned options writes in the real extension,
|
||||
// without creating a note or changing the user's Anki collection.
|
||||
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
|
||||
const metadataServer = http.createServer((request, response) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
request.on('end', () => {
|
||||
const message = JSON.parse(body);
|
||||
assert.equal(message.action, 'multi');
|
||||
const result = message.params.actions.map(({ action }) => ({
|
||||
result:
|
||||
action === 'deckNames'
|
||||
? ['Test Mining']
|
||||
: action === 'modelNames'
|
||||
? ['Custom Japanese']
|
||||
: fields,
|
||||
error: null,
|
||||
}));
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ result, error: null }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const url = `http://127.0.0.1:${metadataServer.address().port}`;
|
||||
const ankiConfig = {
|
||||
tags: ['SubMiner', 'Autofill'],
|
||||
fields: {
|
||||
word: 'Term',
|
||||
sentence: 'Context',
|
||||
wordAudio: 'Pronunciation',
|
||||
image: 'Image',
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
ankiConfig,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const anki = await parserWindow.webContents.executeJavaScript(
|
||||
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
|
||||
);
|
||||
assert.equal(anki.model, 'Custom Japanese');
|
||||
assert.equal(anki.deck, 'Test Mining');
|
||||
assert.deepEqual(anki.tags, [...ankiConfig.tags, 'hachidori']);
|
||||
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
|
||||
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
|
||||
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
|
||||
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
|
||||
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
|
||||
} finally {
|
||||
await new Promise((resolve) => metadataServer.close(resolve));
|
||||
}
|
||||
const archives = [
|
||||
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
|
||||
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
|
||||
]),
|
||||
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
|
||||
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
|
||||
]),
|
||||
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
|
||||
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
|
||||
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
|
||||
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
|
||||
['頻度だけ', 'freq', 17],
|
||||
]),
|
||||
fixture(
|
||||
'occurrences',
|
||||
'SubMiner Test Occurrences',
|
||||
'term_meta_bank_1.json',
|
||||
[['頻度だけ', 'freq', 9000]],
|
||||
'occurrence-based',
|
||||
),
|
||||
];
|
||||
for (const archive of archives)
|
||||
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
|
||||
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
|
||||
assert.equal(dictionaries.length, 4);
|
||||
parser.clearYomitanParserCachesForWindow(parserWindow);
|
||||
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
});
|
||||
assert.equal(tokens[0].isNameMatch, true);
|
||||
assert.equal(tokens[1].headword, '食べる');
|
||||
assert.equal(tokens[1].reading, 'たべた');
|
||||
assert.equal(tokens[1].startPos, 4);
|
||||
assert.equal(tokens[1].endPos, 7);
|
||||
assert.equal(tokens[1].frequencyRank, 42);
|
||||
assert.deepEqual(tokens[1].wordClasses, ['v1']);
|
||||
const exact = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'たべる' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.equal(exact.length, 1);
|
||||
assert.equal(exact[0].frequency, 42);
|
||||
assert.equal(exact[0].reading, 'たべる');
|
||||
assert.equal(exact[0].hasReading, false);
|
||||
const otherReading = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'べつのよみ' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
// The shared frequency pipeline retries a missing reading as a term-only query.
|
||||
assert.equal(otherReading[0]?.frequency, 42);
|
||||
const unmatched = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '頻度だけ', reading: null }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.deepEqual(unmatched, []);
|
||||
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
||||
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
||||
true,
|
||||
);
|
||||
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
|
||||
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
|
||||
const proxyState = await host.webContents.executeJavaScript(
|
||||
"chrome.storage.local.get('subminerAnkiProxyUrl')",
|
||||
);
|
||||
assert.equal(proxyState.subminerAnkiProxyUrl, null);
|
||||
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
|
||||
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
|
||||
}
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(errors.length, 0);
|
||||
console.log(
|
||||
'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
|
||||
);
|
||||
clearTimeout(deadline);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(deadline);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import '../vendor/hachidori/extension/reader-options.js';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
|
||||
|
||||
async function mine({ proxy = true, audioFails = false } = {}) {
|
||||
const events: string[] = [];
|
||||
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
|
||||
let fields: Record<string, string> = {};
|
||||
let initialAudio = '';
|
||||
let stored = false;
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
const { action, params } = JSON.parse(String(options.body));
|
||||
events.push(action);
|
||||
let result: unknown;
|
||||
switch (action) {
|
||||
case 'multi':
|
||||
result = [
|
||||
{ result: ['Default'], error: null },
|
||||
{ result: ['Basic'], error: null },
|
||||
{ result: ['Expression', 'ExpressionAudio'], error: null },
|
||||
];
|
||||
break;
|
||||
case 'canAddNotes':
|
||||
result = [true];
|
||||
break;
|
||||
case 'canAddNotesWithErrorDetail':
|
||||
result = [{ canAdd: true, error: null }];
|
||||
break;
|
||||
case 'getMediaFilesNames':
|
||||
result = stored ? [filename] : [];
|
||||
break;
|
||||
case 'storeMediaFile':
|
||||
stored = true;
|
||||
result = filename;
|
||||
break;
|
||||
case 'addNote':
|
||||
fields = { ...params.note.fields };
|
||||
initialAudio = fields.ExpressionAudio ?? '';
|
||||
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
|
||||
result = 123;
|
||||
break;
|
||||
case 'notesInfo':
|
||||
result = [
|
||||
{
|
||||
noteId: 123,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, { value }]),
|
||||
),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'updateNoteFields':
|
||||
Object.assign(fields, params.note.fields);
|
||||
result = null;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected action: ${action}`);
|
||||
}
|
||||
return Response.json({ result, error: null });
|
||||
},
|
||||
});
|
||||
const service = createAnkiWorkerService({
|
||||
gateway,
|
||||
readOptions: async () => ({
|
||||
anki: {
|
||||
url: 'http://127.0.0.1:8766',
|
||||
apiKey: '',
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
deck: 'Default',
|
||||
model: 'Basic',
|
||||
tags: [],
|
||||
fields: {},
|
||||
duplicateScope: 'model',
|
||||
duplicateBehavior: 'prevent',
|
||||
captureScreenshot: false,
|
||||
fieldTemplates: {
|
||||
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
|
||||
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
audioSources: [
|
||||
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
|
||||
],
|
||||
experimental: {},
|
||||
}),
|
||||
readDictionaries: async () => [],
|
||||
engine: async () => ({ ready: true, loading: false, generation: 1 }),
|
||||
offscreen: async (message: { type: string; audio?: string }) => {
|
||||
if (message.type === 'hd_anki_audio') {
|
||||
events.push('pronunciation');
|
||||
if (audioFails) throw new Error('No pronunciation available');
|
||||
return { filename, data: 'YXVkaW8=' };
|
||||
}
|
||||
return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] };
|
||||
},
|
||||
duplicateIndex: { source: async () => null, recordWrite: async () => {} },
|
||||
});
|
||||
const status = await service.status();
|
||||
assert.equal(status.available, true, status.error);
|
||||
const result = await service.submit({
|
||||
configKey: status.configKey,
|
||||
generation: 1,
|
||||
term: { expression: '猫', reading: 'ねこ' },
|
||||
});
|
||||
return { result, initialAudio, fields, events, filename };
|
||||
}
|
||||
|
||||
test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => {
|
||||
const value = await mine();
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.equal(value.initialAudio, `[sound:${value.filename}]`);
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
});
|
||||
|
||||
test('direct Hachidori keeps deferred pronunciation', async () => {
|
||||
const value = await mine({ proxy: false });
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`);
|
||||
});
|
||||
|
||||
test('unavailable pronunciation remains a warning without a late audio write', async () => {
|
||||
const value = await mine({ audioFails: true });
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.match(value.result.warnings.join(' '), /No pronunciation available/);
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
assert.equal(value.events.includes('updateNoteFields'), false);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
|
||||
const bridge = readFileSync(
|
||||
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function run(code: string) {
|
||||
runInNewContext(`${bridge}\n${code}`, {
|
||||
window: new EventTarget(),
|
||||
EventTarget,
|
||||
CustomEvent,
|
||||
assert,
|
||||
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
|
||||
KeyboardEvent: class {
|
||||
constructor(_type: string, options: KeyboardEventInit = {}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Hachidori marks popup state independently from successful lookups', () => {
|
||||
run(`
|
||||
const events = [];
|
||||
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
|
||||
window.addEventListener(name, () => events.push(name));
|
||||
}
|
||||
const attributes = new Map();
|
||||
const host = { setAttribute: (name, value) => attributes.set(name, value) };
|
||||
SubMinerHachidori.markHost(host, true);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
|
||||
assert.equal(events.join(','), '');
|
||||
SubMinerHachidori.lookup();
|
||||
SubMinerHachidori.markHost(host, false);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
|
||||
assert.equal(events.join(','), 'subminer-yomitan-lookup');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
|
||||
run(`
|
||||
const result = (id, matched, dictionary, options = {}) => ({
|
||||
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
|
||||
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
|
||||
});
|
||||
const character = characterPrefix + ' - Current show';
|
||||
const longer = result('longer', '花子さん', 'General');
|
||||
const normal = result('normal', '花子', 'General');
|
||||
const person = result('person', '花子', character);
|
||||
const shorter = result('shorter', '花', character);
|
||||
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
|
||||
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
|
||||
const merged = result('merged', '花子', 'General');
|
||||
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
|
||||
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
|
||||
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
|
||||
assert.equal(merged.term.glossaries[0].dictionary, 'General');
|
||||
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
|
||||
const transformed = { ...person, ...change };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
|
||||
}
|
||||
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
|
||||
const aliased = result('aliased', '花子', 'Imported character data');
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
|
||||
const requests: Array<{ url: string; body: string }> = [];
|
||||
const proxy = 'http://127.0.0.1:8766';
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => proxy,
|
||||
fetch: async (url: string, options: RequestInit) => {
|
||||
assert.equal(typeof options.body, 'string');
|
||||
requests.push({ url, body: String(options.body) });
|
||||
return Response.json({ result: 123, error: null });
|
||||
},
|
||||
});
|
||||
const params = {
|
||||
note: { fields: { Expression: '花子' } },
|
||||
subminerDuplicateNoteIds: [456],
|
||||
subminerEnrich: true,
|
||||
};
|
||||
await gateway.invoke('addNote', params, '', 1000, proxy);
|
||||
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
|
||||
assert.deepEqual(
|
||||
requests.map((request) => JSON.parse(request.body).params),
|
||||
[params, { note: params.note }],
|
||||
);
|
||||
assert.equal(params.subminerEnrich, true);
|
||||
const direct = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => null,
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
|
||||
return Response.json({ result: null, error: null });
|
||||
},
|
||||
});
|
||||
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
|
||||
});
|
||||
|
||||
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
|
||||
run(`
|
||||
const calls = [];
|
||||
const disconnect = SubMinerHachidori.connect({
|
||||
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
|
||||
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
|
||||
scroll: (x, y) => calls.push(x + ':' + y),
|
||||
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
|
||||
});
|
||||
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
|
||||
send(null);
|
||||
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
|
||||
send({ type: 'mineSelected' });
|
||||
send({ type: 'playCurrentAudio' });
|
||||
send({ type: 'scanSelectedText' });
|
||||
send({ type: 'cycleAudioSource', direction: -1 });
|
||||
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
|
||||
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
|
||||
send({ type: 'setVisible', visible: false });
|
||||
send({ type: 'clearActiveTextSource' });
|
||||
disconnect();
|
||||
send({ type: 'mineSelected' });
|
||||
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
|
||||
`);
|
||||
});
|
||||
@@ -26,6 +26,14 @@ const REQUIRED_APP_FILES = [
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'hachidori/manifest.json',
|
||||
'hachidori/settings.html',
|
||||
'hachidori/subminer-host.js',
|
||||
'hachidori/vendor/hoshidicts.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded-idbfs.wasm',
|
||||
'hachidori/LICENSE',
|
||||
'hachidori/SOURCE.json',
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
|
||||
@@ -18,7 +18,7 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
||||
'/tmp/subminer-filter-output',
|
||||
{
|
||||
defaultSrc: root,
|
||||
globalOutDir: path.join(root, 'release'),
|
||||
globalOutDir: path.join(root, 'dist', 'release'),
|
||||
customBuildOptions: { files: config.build[platform].files },
|
||||
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
||||
},
|
||||
@@ -51,6 +51,9 @@ test('platform packaging preserves the runtime allowlist after builder normalize
|
||||
'stats/dist/nested/fixtures/data.json',
|
||||
'vendor/texthooker-ui/docs/nested/component.tsx',
|
||||
'dist/launcher/subminer',
|
||||
'dist/yomitan/manifest.json',
|
||||
'dist/hachidori/manifest.json',
|
||||
'dist/release/SubMiner-1.0.0.AppImage',
|
||||
'dist/settings/fonts/MPLUS1[wght].ttf',
|
||||
'vendor/subminer-yomitan/ext/manifest.json',
|
||||
]) {
|
||||
|
||||
@@ -10,14 +10,20 @@ import { Readable } from 'node:stream';
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json');
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||
repoRoot,
|
||||
'packaging',
|
||||
'bun',
|
||||
'bun-source-manifest.json',
|
||||
);
|
||||
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
||||
repoRoot,
|
||||
'build',
|
||||
'packaging',
|
||||
'bun',
|
||||
'bun-runtime-manifest.json',
|
||||
);
|
||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release');
|
||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'dist', 'release');
|
||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
||||
|
||||
function isRecord(value) {
|
||||
|
||||
@@ -4,6 +4,9 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_MANIFEST_PATH,
|
||||
DEFAULT_PACKAGE_JSON_PATH,
|
||||
DEFAULT_RUNTIME_MANIFEST_PATH,
|
||||
downloadArchive,
|
||||
parseRegisteredRepositories,
|
||||
parseSourceManifest,
|
||||
@@ -67,10 +70,26 @@ describe('Bun corresponding-source manifest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('default manifest paths resolve to aligned checked-in manifests', async () => {
|
||||
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
|
||||
fs.readFile(DEFAULT_MANIFEST_PATH, 'utf8'),
|
||||
fs.readFile(DEFAULT_RUNTIME_MANIFEST_PATH, 'utf8'),
|
||||
fs.readFile(DEFAULT_PACKAGE_JSON_PATH, 'utf8'),
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(
|
||||
parseSourceManifest(JSON.parse(manifestText)),
|
||||
JSON.parse(packageJsonText),
|
||||
JSON.parse(runtimeManifestText),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('rejects stale runtime or package-manager pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const runtimeManifest = {
|
||||
@@ -100,7 +119,7 @@ describe('Bun corresponding-source manifest', () => {
|
||||
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
await fs.readFile(path.join(projectRoot, 'packaging/bun/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const registrations = manifest.sources
|
||||
|
||||
@@ -6,7 +6,7 @@ cat <<'EOF'
|
||||
patch-yomitan.sh is retired.
|
||||
|
||||
SubMiner now uses the forked source submodule at vendor/subminer-yomitan and builds the
|
||||
Chromium extension artifact into build/yomitan.
|
||||
Chromium extension artifact into dist/yomitan.
|
||||
|
||||
Use:
|
||||
git submodule update --init --recursive
|
||||
|
||||
@@ -9,9 +9,9 @@ paths=(
|
||||
"tsconfig.json"
|
||||
"tsconfig.renderer.json"
|
||||
"tsconfig.typecheck.json"
|
||||
".prettierrc.json"
|
||||
".github"
|
||||
"build"
|
||||
"packaging/bun/bun-runtime-manifest.json"
|
||||
"packaging/bun/bun-source-manifest.json"
|
||||
"e2e"
|
||||
"launcher"
|
||||
"scripts"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachi-settings-'));
|
||||
const env = { ...process.env, SUBMINER_DICTIONARY_SMOKE_DATA: profile };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[
|
||||
'--no-sandbox',
|
||||
fileURLToPath(new URL('./check-dictionary-backends.cjs', import.meta.url)),
|
||||
...process.argv.slice(2),
|
||||
],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
+45
-49
@@ -22,39 +22,6 @@ const timeout = setTimeout(() => {
|
||||
app.exit(1);
|
||||
}, 60_000);
|
||||
|
||||
const STATIC_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
|
||||
// The stats dashboard is served by the stats HTTP server in the app, so load it
|
||||
// over loopback HTTP from the packaged stats/dist and treat missing static
|
||||
// assets as failures. API routes are not part of this smoke and may 404.
|
||||
function serveStatsDist(root, failedRequests) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
|
||||
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(root, relative));
|
||||
res.writeHead(200, {
|
||||
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
|
||||
async function smoke() {
|
||||
await app.whenReady();
|
||||
const packagedRequire = createRequire(path.join(archive, 'package.json'));
|
||||
@@ -83,6 +50,10 @@ async function smoke() {
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const hachidori = await session
|
||||
.fromPartition('persist:hachidori')
|
||||
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
|
||||
assert(hachidori.id, 'Hachidori extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred(
|
||||
{ urls: ['file://*/*', 'http://127.0.0.1/*'] },
|
||||
@@ -93,9 +64,7 @@ async function smoke() {
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
},
|
||||
);
|
||||
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
|
||||
await once(statsServer, 'listening');
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -104,25 +73,52 @@ async function smoke() {
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (ui === 'stats') {
|
||||
await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
|
||||
// Let in-flight font requests settle before the window goes away.
|
||||
await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||
} else {
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
}
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
} finally {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
statsServer.close();
|
||||
// The stats dashboard uses HTTP for both assets and API requests in the app.
|
||||
const { ImmersionTrackerService } = packagedRequire(
|
||||
'./dist/core/services/immersion-tracker-service.js',
|
||||
);
|
||||
const { createStatsApp, startNodeHttpServer } = packagedRequire(
|
||||
'./dist/core/services/stats-server.js',
|
||||
);
|
||||
const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') });
|
||||
const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker };
|
||||
let statsHttp;
|
||||
const statsServer = await startNodeHttpServer(
|
||||
createStatsApp(tracker, statsConfig),
|
||||
statsConfig,
|
||||
(listener) => (statsHttp = http.createServer(listener)),
|
||||
);
|
||||
const statsWindow = new BrowserWindow({ show: false });
|
||||
try {
|
||||
const url = `http://127.0.0.1:${statsHttp.address().port}`;
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => {
|
||||
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
|
||||
});
|
||||
await statsWindow.loadURL(url);
|
||||
// Let in-flight font requests settle before the window goes away.
|
||||
await statsWindow.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
|
||||
for (const endpoint of ['overview', 'sessions']) {
|
||||
const response = await fetch(`${url}/api/stats/${endpoint}`);
|
||||
assert.equal(response.status, 200, `Stats ${endpoint} request failed`);
|
||||
await response.json();
|
||||
}
|
||||
} finally {
|
||||
statsWindow.destroy();
|
||||
await statsServer.close();
|
||||
tracker.destroy();
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,14 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json');
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(
|
||||
repoRoot,
|
||||
'packaging',
|
||||
'bun',
|
||||
'bun-runtime-manifest.json',
|
||||
);
|
||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
||||
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses');
|
||||
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'packaging', 'bun', 'licenses');
|
||||
export const STAGED_METADATA_FILE = 'metadata.json';
|
||||
export const REQUIRED_LICENSE_FILES = [
|
||||
'Bun-LICENSE.md',
|
||||
|
||||
@@ -35,7 +35,7 @@ function runtimeConfig() {
|
||||
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
||||
},
|
||||
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,7 @@ test('manifest version must match the exact packageManager Bun pin', () => {
|
||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||
artifacts: {},
|
||||
licenseInventoryStatus: 'partial',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
sourceManifest: 'packaging/bun/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
},
|
||||
'1.3.5',
|
||||
|
||||
+10
-6
@@ -45,6 +45,7 @@ import {
|
||||
} from './anki-field-config';
|
||||
import { createLogger } from './logger';
|
||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||
import {
|
||||
createUiFeedbackState,
|
||||
beginUpdateProgress,
|
||||
@@ -1029,10 +1030,10 @@ export class AnkiIntegration {
|
||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||
) {
|
||||
return {
|
||||
startTime: this.mpvClient.currentSubStart,
|
||||
endTime: this.mpvClient.currentSubEnd,
|
||||
};
|
||||
return shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||
this.mpvClient.currentSubDelay,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||
@@ -1672,7 +1673,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
audioFilename,
|
||||
this.mpvClient.currentSubStart,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1695,7 +1696,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
imageFilename,
|
||||
this.mpvClient.currentSubStart,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1802,6 +1803,9 @@ export class AnkiIntegration {
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
log.debug(
|
||||
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
|
||||
);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
|
||||
@@ -441,6 +441,127 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
|
||||
const received: unknown[] = [];
|
||||
let upstreamError: string | null = null;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: null, error: upstreamError }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
added.push(count);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const [id, marked, error] of [
|
||||
[51, true, null],
|
||||
[52, false, null],
|
||||
[53, true, 'failed'],
|
||||
] satisfies Array<[number, boolean, string | null]>) {
|
||||
upstreamError = error;
|
||||
await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'updateNoteFields',
|
||||
version: 6,
|
||||
params: {
|
||||
note: { id, fields: { Expression: '猫' } },
|
||||
...(marked ? { subminerEnrich: true } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
await waitForCondition(() => processed.length > 0);
|
||||
assert.deepEqual(processed, [51]);
|
||||
assert.deepEqual(added, []);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('stats-owned notes bypass overlay enrichment while popup notes still enqueue', async () => {
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const received: unknown[] = [];
|
||||
let noteId = 70;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.end(JSON.stringify({ result: ++noteId, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (_count, ids) => {
|
||||
added.push(...ids);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const metadata of [{ subminerEnrich: false }, {}]) {
|
||||
const response: Response = await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: { note: { fields: { Expression: '猫' } }, ...metadata },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
await response.json();
|
||||
}
|
||||
await waitForCondition(() => processed.includes(72));
|
||||
assert.deepEqual(processed, [72]);
|
||||
assert.deepEqual(added, [71, 72]);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('subminerEnrich' in params) ||
|
||||
params.subminerEnrich !== true
|
||||
)
|
||||
return;
|
||||
const note = 'note' in params ? params.note : null;
|
||||
if (!note || typeof note !== 'object' || !('id' in note)) return;
|
||||
const response = this.tryParseJsonValue(responseBody);
|
||||
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!('error' in response) ||
|
||||
response.error !== null ||
|
||||
!('result' in response) ||
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||
return;
|
||||
}
|
||||
@@ -239,12 +264,23 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
: this.collectNoteIdsForAction(action, responseResult);
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
action === 'addNote' &&
|
||||
params &&
|
||||
typeof params === 'object' &&
|
||||
'subminerEnrich' in params &&
|
||||
params.subminerEnrich === false
|
||||
) {
|
||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
return;
|
||||
}
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
return;
|
||||
@@ -293,7 +329,7 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
if (action !== 'addNote' && action !== 'updateNoteFields') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
@@ -301,12 +337,17 @@ export class AnkiConnectProxyServer {
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
if (
|
||||
!params ||
|
||||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
|
||||
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
|
||||
) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
delete nextParams.subminerEnrich;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
@@ -455,7 +496,7 @@ export class AnkiConnectProxyServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
private enqueueNotes(noteIds: number[], recordAdded = true): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
@@ -472,7 +513,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type MediaTimingReviewRequest,
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -428,10 +429,12 @@ export class CardCreationService {
|
||||
return;
|
||||
}
|
||||
|
||||
let startTime = mpvClient.currentSubStart;
|
||||
let endTime = mpvClient.currentSubEnd;
|
||||
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||
mpvClient.currentSubDelay,
|
||||
);
|
||||
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
|
||||
const currentTime = mpvClient.currentTimePos || 0;
|
||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||
startTime = currentTime - fallback;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
@@ -110,6 +111,27 @@ function createWorkflowHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow preserves stats cards discovered by polling', async () => {
|
||||
const { workflow, deps, updates } = createWorkflowHarness();
|
||||
const note = {
|
||||
noteId: 42,
|
||||
tags: [STATS_MINING_TAG],
|
||||
fields: { Expression: { value: '猫' }, Sentence: { value: '猫がいる。' } },
|
||||
};
|
||||
deps.client.notesInfo = async () => [note];
|
||||
deps.captureSubtitleMediaContext = () => assert.fail('Must not capture current playback');
|
||||
deps.findDuplicateNote = async () => assert.fail('Must not regroup a stats card');
|
||||
let cachedNote: NoteUpdateWorkflowNoteInfo | undefined;
|
||||
deps.appendKnownWordsFromNoteInfo = (value) => {
|
||||
cachedNote = value;
|
||||
};
|
||||
|
||||
await workflow.execute(42);
|
||||
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(cachedNote, note);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import type {
|
||||
WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
tags?: string[];
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
@@ -185,6 +187,10 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
if (noteInfo.tags?.includes(STATS_MINING_TAG)) {
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
return;
|
||||
}
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -476,3 +477,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
|
||||
const args = parseArgs(['--hachidori']);
|
||||
assert.equal(args.hachidori, true);
|
||||
assert.equal(args.yomitan, false);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
|
||||
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
|
||||
assert.equal(
|
||||
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
|
||||
});
|
||||
+11
-1
@@ -12,6 +12,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
youtubeBrowser: boolean;
|
||||
@@ -133,6 +134,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
youtubeBrowser: false,
|
||||
@@ -279,6 +281,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--hachidori') args.hachidori = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--youtube-browser') args.youtubeBrowser = true;
|
||||
@@ -563,6 +566,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -643,6 +647,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.youtubeBrowser &&
|
||||
@@ -716,6 +721,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.youtubeBrowser ||
|
||||
@@ -767,8 +773,12 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
}
|
||||
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
|
||||
}
|
||||
|
||||
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
args.yomitan &&
|
||||
(args.yomitan || args.hachidori) &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--yomitan Open Yomitan settings window
|
||||
--hachidori Open Hachidori settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
} from './definitions/shared';
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG;
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
|
||||
@@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
enumValues: ['yomitan', 'hachidori'],
|
||||
defaultValue: defaultConfig.dictionaryBackend,
|
||||
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
|
||||
},
|
||||
{
|
||||
path: 'logging.level',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -554,7 +554,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.executablePath,
|
||||
description:
|
||||
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
|
||||
'Optional absolute path to the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.launchMode',
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Dictionary Backend',
|
||||
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
|
||||
notes: [
|
||||
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
@@ -215,7 +223,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
'autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.',
|
||||
'Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.',
|
||||
'Set mpv.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
|
||||
'Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.',
|
||||
],
|
||||
key: 'mpv',
|
||||
},
|
||||
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user