Compare commits

..

7 Commits

Author SHA1 Message Date
5d96f9d535 Add annotation websocket and texthooker startup config
- Add `texthooker.launchAtStartup` (default `true`) and wire startup behavior
- Add dedicated `annotationWebsocket` config/service path (default port `6678`) for texthooker annotations
- Regenerate config example/tests and update Yomitan patching/vendor assets
2026-03-07 01:41:08 -08:00
1d76e05cd3 fix(subtitle): tighten frequency token filtering 2026-03-07 01:28:37 -08:00
3dff6c2515 feat: add first-run setup flow 2026-03-07 00:57:09 -08:00
755c1175b0 fix(dictionary): add configurable collapsible section defaults 2026-03-06 23:46:24 -08:00
78cd99a2d0 fix: index AniList character aliases in dictionary 2026-03-06 22:02:04 -08:00
1fd3f0575b fix: prioritize SubMiner definitions in Yomitan 2026-03-06 22:01:20 -08:00
ca0eec568c fix: quiet default appimage startup 2026-03-06 21:17:47 -08:00
96 changed files with 5367 additions and 275 deletions

View File

@@ -33,6 +33,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
- **Annotated websocket API** — Dedicated annotation feed can serve bundled texthooker or external clients with rendered `sentence` HTML plus structured `tokens`
- **Jellyfin integration** — Remote playback setup, cast device mode, and direct playback launch
- **AniList progress** — Track episode completion and push watching progress automatically
@@ -55,28 +56,34 @@ chmod +x ~/.local/bin/subminer
**From source** or **macOS** — see the [installation guide](https://docs.subminer.moe/installation#from-source).
### 2. Install the mpv plugin and configuration file
### 2. Launch the app once
```bash
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.config/mpv/scripts/subminer
mkdir -p ~/.config/mpv/script-opts
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
SubMiner.AppImage
```
### 3. Set up Yomitan Dictionaries
On first launch, SubMiner now:
```bash
subminer app --yomitan
```
- starts in the tray/background
- creates the default config directory and `config.jsonc`
- opens a compact setup popup
- can install the mpv plugin to the default mpv scripts location for you
- links directly to Yomitan settings so you can install dictionaries before finishing setup
Existing installs that already have a valid config plus at least one Yomitan dictionary are auto-detected as complete and will not be re-prompted.
### 3. Finish setup
- click `Install mpv plugin` if you want the default plugin auto-start flow
- click `Open Yomitan Settings` and install at least one dictionary
- click `Refresh status`
- click `Finish setup`
The mpv plugin step is optional. Yomitan must report at least one installed dictionary before setup can be completed.
### 4. Mine
```bash
subminer app --start --background
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
```

View File

@@ -0,0 +1,49 @@
---
id: TASK-100
title: 'Add configurable texthooker startup launch'
status: Done
assignee: []
created_date: '2026-03-06 23:30'
updated_date: '2026-03-07 01:59'
labels: []
dependencies: []
priority: medium
ordinal: 10000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a config option under `texthooker` to launch the built-in texthooker server automatically when SubMiner starts.
Scope:
- Add `texthooker.launchAtStartup`.
- Default to `true`.
- Start the existing texthooker server during normal app startup when enabled.
- Keep `texthooker.openBrowser` as separate behavior.
- Add regression coverage and update generated config docs/example.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Default config enables automatic texthooker startup.
- [x] #2 Config parser accepts valid boolean values and warns on invalid values.
- [x] #3 App-ready startup launches texthooker when enabled.
- [x] #4 Generated config template/example documents the new option.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added `texthooker.launchAtStartup` with a default of `true`, wired it through config defaults/validation/template generation, and started the existing texthooker server during app-ready startup without coupling it to browser auto-open behavior.
Also added regression coverage for config parsing/template output and app-ready dependency wiring, then regenerated the checked-in config example artifacts.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,39 @@
---
id: TASK-101
title: Index AniList character alternative names in the character dictionary
status: Done
assignee: []
created_date: '2026-03-07 00:00'
updated_date: '2026-03-07 00:00'
labels:
- dictionary
- anilist
priority: high
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.ts
- /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.test.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Index AniList character alternative names in generated character dictionaries so aliases like Shadow resolve during subtitle lookup instead of falling through to unrelated generic dictionary entries.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Character fetch reads AniList alternative character names needed for lookup coverage
- [x] #2 Generated term banks include alias-derived terms for subtitle lookups like シャドウ
- [x] #3 Regression coverage proves alternative-name indexing works end to end
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Character dictionary generation now requests AniList `name.alternative`, indexes those aliases as term candidates, and expands mixed aliases like `Minoru Kagenou (影野ミノル)` into usable outer/inner variants. Also extended kana alias synthesis so the AniList alias `Shadow` emits `シャドウ`, which matches the subtitle token the user hit in The Eminence in Shadow.
Bumped the character-dictionary snapshot format to invalidate stale cached snapshots, and updated merged-dictionary rebuilds to refresh invalid snapshots before composing the ZIP so old cache files do not hard-fail the merge path.
Verified with `bun test src/main/character-dictionary-runtime.test.ts` and `bun run tsc --noEmit`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,65 @@
---
id: TASK-102
title: Quiet default AppImage startup and implicit background launch
status: Done
assignee:
- codex
created_date: '2026-03-06 21:20'
updated_date: '2026-03-06 21:33'
labels: []
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/src/main-entry-runtime.ts
- /home/sudacode/projects/japanese/SubMiner/src/core/services/cli-command.ts
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make the packaged Linux no-arg launch path behave like a quiet background start instead of surfacing startup-only noise.
Scope:
- Treat default background entry launches as implicit `--start --background`.
- Keep the `--password-store` diagnostic out of normal startup output.
- Suppress known startup-only `node:sqlite` and `lsfg-vk` warnings for the entry/background launch path.
- Avoid noisy protocol-registration warnings during normal startup when registration is unsupported.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Initial background launch reaches the start path without logging `No running instance. Use --start to launch the app.`
- [x] #2 Default startup no longer emits the `Applied --password-store gnome-libsecret` line at normal log levels.
- [x] #3 Entry/background launch sanitization suppresses the observed `ExperimentalWarning: SQLite...` and `lsfg-vk ... unsupported configuration version` startup noise.
- [x] #4 Regression coverage documents the new startup behavior.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Normalized no-arg/password-store-only entry launches to append implicit `--start --background`, and upgraded `--background`-only entry launches to include `--start`.
Applied shared entry env sanitization before loading the main process so default startup strips the `lsfg-vk` Vulkan layer and sets `NODE_NO_WARNINGS=1`; background children keep the same sanitized env.
Downgraded startup-only protocol-registration failure logging to debug, and routed the Linux password-store diagnostic through the scoped debug logger instead of raw console output.
Verification:
- `bun test src/main-entry-runtime.test.ts src/main/runtime/anilist-setup-protocol.test.ts src/main/runtime/anilist-setup-protocol-main-deps.test.ts`
- `bun run test:fast`
Note: the final `node --experimental-sqlite --test dist/main/runtime/registry.test.js` step in `bun run test:fast` still prints Node's own experimental SQLite warning because that test command explicitly enables the feature flag outside the app entrypoint.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Default packaged startup is now quiet and behaves like an implicit `--start --background` launch.
- No-arg AppImage entry launches now append `--start --background`, and `--background`-only launches append the missing `--start`.
- Entry/background startup sanitization now suppresses the observed `lsfg-vk` and `node:sqlite` warnings on the app launch path.
- Linux password-store and unsupported protocol-registration diagnostics now stay at debug level instead of normal startup output.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-103
title: Add dedicated annotation websocket for texthooker
status: Done
assignee:
- codex
created_date: '2026-03-07 02:20'
updated_date: '2026-03-07 02:20'
labels:
- texthooker
- websocket
- subtitle
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a separate annotated subtitle websocket for bundled texthooker so token/JLPT/frequency markup is available on a stable dedicated port even when the regular websocket is in `auto` mode and skipped because `mpv_websocket` is installed.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Regular `websocket.enabled: "auto"` behavior remains unchanged and still skips the regular websocket when `mpv_websocket` is installed.
- [x] #2 A separate `annotationWebsocket` config controls an independent annotated websocket with default port `6678`.
- [x] #3 Bundled texthooker is pointed at the annotation websocket when it is enabled.
- [x] #4 Focused regression tests cover config parsing, startup wiring, and texthooker bootstrap injection.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added `annotationWebsocket.enabled`/`annotationWebsocket.port` with defaults of `true`/`6678`, started that websocket independently from the regular auto-managed websocket, and injected the bundled texthooker websocket URL so it connects to the annotation feed by default.
Also added focused regression coverage and regenerated the checked-in config examples.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-104
title: Mirror overlay annotation hover behavior in vendored texthooker
status: Done
assignee:
- codex
created_date: '2026-03-06 21:45'
updated_date: '2026-03-06 21:45'
labels:
- texthooker
- subtitle
- websocket
dependencies:
- TASK-103
references:
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-ws.ts
- /home/sudacode/projects/japanese/SubMiner/vendor/texthooker-ui/src/components/App.svelte
- /home/sudacode/projects/japanese/SubMiner/vendor/texthooker-ui/src/line-markup.ts
- /home/sudacode/projects/japanese/SubMiner/vendor/texthooker-ui/src/app.css
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Bring bundled texthooker annotation rendering closer to the visible overlay. Keep the lightweight texthooker UX, but preserve token metadata for hover, match overlay color-precedence rules across known/N+1/name/frequency/JLPT, expose name-match highlighting as a toggle, and emit a structured annotation payload on the dedicated websocket so non-SubMiner clients can treat it as an API.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Annotation websocket payload includes both rendered `sentence` HTML and structured token metadata for generic clients.
- [x] #2 Vendored texthooker preserves annotation metadata attrs needed for hover labels and uses overlay-matching color precedence rules.
- [x] #3 Vendored texthooker supports character-name highlighting with a user-facing toggle and standalone-web note.
- [x] #4 Hovering annotated texthooker tokens reveals JLPT/frequency metadata without adding the full overlay popup workflow.
- [x] #5 Focused serializer, texthooker markup, socket parsing, CSS, and build verification pass.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Extended the dedicated annotation websocket payload to ship `version`, plain `text`, rendered `sentence`, and structured `tokens` metadata while keeping backward-compatible `sentence` consumers working. Updated the vendored texthooker to preserve hover metadata attrs, follow overlay color precedence for known/N+1/name/frequency/JLPT annotations, add a character-name highlight toggle plus standalone-web dictionary note, and render lightweight hover labels for frequency/JLPT metadata. Added focused regression coverage and rebuilt both the vendored texthooker bundle and SubMiner.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-105
title: Stop local docs artifact writes after docs repo split
status: Done
assignee: []
created_date: '2026-03-07 00:00'
updated_date: '2026-03-07 00:20'
labels: []
dependencies: []
priority: medium
ordinal: 10500
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Now that user-facing docs live in `../subminer-docs`, first-party scripts in this repo should not keep writing generated artifacts into the local `docs/` tree.
Scope:
- Audit first-party scripts/automation for writes to `docs/`.
- Keep repo-local outputs only where they are still intentionally owned by this repo.
- Repoint generated docs artifacts to `../subminer-docs` when that is the maintained source of truth.
- Add regression coverage for the config-example generation path contract.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The config-example generator no longer writes to `docs/public/config.example.jsonc` inside this repo.
- [x] #2 When `../subminer-docs` exists, the generator updates `../subminer-docs/public/config.example.jsonc`.
- [x] #3 Automated coverage guards the output-path contract so local docs writes do not regress.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the first-party local `docs/public` config-example write path from `src/generate-config-example.ts` and replaced it with sibling-docs-repo detection that targets `../subminer-docs/public/config.example.jsonc` only when that repo exists.
Added a project-local regression suite for output-path resolution and artifact writing, wired that suite into the maintained config test lane, and removed the stale generated `docs/public/config.example.jsonc` artifact from the working tree.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,69 @@
---
id: TASK-106
title: Add first-run setup gate and auto-install flow
status: Done
assignee:
- codex
created_date: '2026-03-07 06:10'
updated_date: '2026-03-07 06:20'
labels: []
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
- /home/sudacode/projects/japanese/SubMiner/src/shared/setup-state.ts
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/first-run-setup-service.ts
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/first-run-setup-window.ts
- /home/sudacode/projects/japanese/SubMiner/launcher/commands/playback-command.ts
priority: high
ordinal: 10600
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the current manual install flow with a first-run setup gate:
- bootstrap the default config dir/config file automatically
- detect legacy installs and mark them complete when config + Yomitan dictionaries are already present
- open a compact Catppuccin Macchiato setup popup for incomplete installs
- optionally install the mpv plugin into the default mpv location
- block launcher playback until setup completes, then resume the original playback flow
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 First app launch seeds the default config dir/config file without manual copy steps.
- [x] #2 Existing installs with config plus at least one Yomitan dictionary are auto-detected as already complete.
- [x] #3 Incomplete installs get a first-run setup popup with mpv plugin install, Yomitan settings, refresh, skip, and finish actions.
- [x] #4 Launcher playback waits for setup completion and does not start mpv while setup is incomplete.
- [x] #5 Plugin assets are packaged into the Electron bundle and regression tests cover the new flow.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added shared setup-state/config/mpv path helpers so Electron and launcher read the same onboarding state file.
Introduced a first-run setup service plus compact BrowserWindow popup using Catppuccin Macchiato styling. The popup supports optional mpv plugin install, opening Yomitan settings, status refresh, skip-plugin, and gated finish once at least one Yomitan dictionary is installed.
Electron startup now bootstraps a default config file, auto-detects legacy-complete installs, adds `--setup` CLI support, exposes a tray `Complete Setup` action while incomplete, and avoids reopening setup once completion is recorded.
Launcher playback now checks the shared setup-state file before starting mpv. If setup is incomplete, it launches the app with `--background --setup`, waits for completion, and only then proceeds.
Verification:
- `bun run typecheck`
- `bun run test:fast`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
SubMiner now supports a download-and-launch install flow.
- First launch auto-creates config and opens setup only when needed.
- Existing users with working installs are silently migrated to completed setup.
- The setup popup handles optional mpv plugin install and Yomitan dictionary readiness.
- Launcher playback is gated on setup completion and resumes automatically afterward.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,42 @@
---
id: TASK-107
title: 'Fix Yomitan scan-token fallback fragmentation on exact-source misses'
status: Done
assignee: []
created_date: '2026-03-07 01:10'
updated_date: '2026-03-07 01:12'
labels: []
dependencies: []
priority: high
ordinal: 9007
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Left-to-right Yomitan scanning can emit bogus fallback tokens when `termsFind` returns entries but none of their headwords carries an exact primary source for the consumed substring. Repro: `だが それでも届かぬ高みがあった` currently yields trailing fragments like `があ` / `た`, which blocks the real `あった` token from receiving frequency highlighting.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Scanner skips `termsFind` fallback entries that are not backed by an exact primary source for the consumed substring.
- [x] #2 Repro line no longer yields bogus trailing fragments such as `があ`.
- [x] #3 Regression coverage added for the scan-token path.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the scan-token helper fallback that previously emitted a token from the first returned headword even when Yomitan did not report an exact primary source for the consumed substring. Added a focused regression test covering `だが それでも届かぬ高みがあった`, ensuring bogus `があ` fragmentation is skipped so the later `あった` exact match can still be tokenized and highlighted.
Verification:
- `bun test src/core/services/tokenizer/yomitan-parser-runtime.test.ts src/core/services/tokenizer.test.ts --timeout 20000`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-108
title: 'Exclude single kana tokens from frequency highlighting'
status: Done
assignee: []
created_date: '2026-03-07 01:18'
updated_date: '2026-03-07 01:22'
labels: []
dependencies: []
priority: medium
ordinal: 9008
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Suppress frequency highlighting for single-character hiragana or katakana tokens. Scope is frequency-only: known/N+1/JLPT behavior stays unchanged.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Single-character hiragana tokens do not retain `frequencyRank`.
- [x] #2 Single-character katakana tokens do not retain `frequencyRank`.
- [x] #3 Regression coverage exists at annotation-stage and tokenizer levels.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added a frequency-only suppression rule for single-character kana tokens based on token `surface`, so bogus merged fragments like `た` and standalone one-character kana no longer keep `frequencyRank`. Regression coverage now exists both in the annotation stage and in the tokenizer path, while multi-character tokens and N+1/JLPT behavior remain unchanged.
Verification:
- `bun test src/core/services/tokenizer/annotation-stage.test.ts --timeout 20000`
- `bun test src/core/services/tokenizer.test.ts --timeout 20000`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,38 @@
---
id: TASK-99
title: Add configurable character dictionary collapsible section open states
status: Done
assignee: []
created_date: '2026-03-07 00:00'
updated_date: '2026-03-07 00:00'
labels:
- dictionary
- config
references:
- /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.ts
- /home/sudacode/projects/japanese/SubMiner/src/config/resolve/integrations.ts
- /home/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-integrations.ts
priority: medium
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add per-section config for character dictionary collapsible glossary sections so Description, Character Information, and Voiced by can each default open or closed independently. Default all sections closed.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Config supports `anilist.characterDictionary.collapsibleSections.description`.
- [x] #2 Config supports `anilist.characterDictionary.collapsibleSections.characterInformation`.
- [x] #3 Config supports `anilist.characterDictionary.collapsibleSections.voicedBy`.
- [x] #4 Default config keeps all generated character dictionary collapsible sections closed.
- [x] #5 Regression coverage verifies config parsing/warnings and generated glossary `details.open` behavior.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added per-section open-state config under `anilist.characterDictionary.collapsibleSections` for `description`, `characterInformation`, and `voicedBy`, all defaulting to `false`. Wired the glossary generator to read those settings so generated `details.open` matches config, and added regression coverage for defaults, parsing/warnings, registry exposure, and runtime glossary output.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -5,6 +5,7 @@
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
*/
{
// ==========================================
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
@@ -13,11 +14,12 @@
// ==========================================
// Texthooker Server
// Control whether browser opens automatically for texthooker.
// Configure texthooker startup launch and browser opening behavior.
// ==========================================
"texthooker": {
"openBrowser": true, // Open browser setting. Values: true | false
}, // Control whether browser opens automatically for texthooker.
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": true // Open browser setting. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior.
// ==========================================
// WebSocket Server
@@ -26,17 +28,41 @@
// ==========================================
"websocket": {
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
"port": 6677, // Built-in subtitle websocket server port.
"port": 6677 // Built-in subtitle websocket server port.
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
// ==========================================
// Annotation WebSocket
// Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
// Independent from websocket.auto and defaults to port 6678.
// ==========================================
"annotationWebsocket": {
"enabled": true, // Annotated subtitle websocket server enabled state. Values: true | false
"port": 6678 // Annotated subtitle websocket server port.
}, // Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.
// ==========================================
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity. Keep this as an object; do not replace with a bare string.
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
}, // Controls logging verbosity.
// ==========================================
// Startup Warmups
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// Disable individual warmups to defer load until first real usage.
// lowPowerMode defers all warmups except Yomitan extension.
// ==========================================
"startupWarmups": {
"lowPowerMode": false, // Defer startup warmups except Yomitan extension. Values: true | false
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
// Keyboard Shortcuts
@@ -56,7 +82,7 @@
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -76,7 +102,7 @@
"secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover", // Default mode setting.
"defaultMode": "hover" // Default mode setting.
}, // Dual subtitle track options.
// ==========================================
@@ -88,7 +114,7 @@
"alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting.
"replace": true, // Replace active subtitle file when synchronization succeeds.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths.
// ==========================================
@@ -96,7 +122,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10, // Y percent setting.
"yPercent": 10 // Y percent setting.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
@@ -108,8 +134,11 @@
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
"fontSize": 35, // Font size setting.
"fontColor": "#cad3f5", // Font color setting.
@@ -130,16 +159,22 @@
"N2": "#f5a97f", // N2 setting.
"N3": "#f9e2af", // N3 setting.
"N4": "#a6e3a1", // N4 setting.
"N5": "#8aadf4", // N5 setting.
"N5": "#8aadf4" // N5 setting.
}, // Jlpt colors setting.
"frequencyDictionary": {
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used.
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
"matchMode": "headword", // headword: frequency lookup uses dictionary form. surface: lookup uses subtitle-visible token text. Values: headword | surface
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#8bd5ca", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#8bd5ca",
"#8aadf4"
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
}, // Frequency dictionary setting.
"secondary": {
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
@@ -154,8 +189,8 @@
"backgroundColor": "transparent", // Background color setting.
"backdropFilter": "blur(6px)", // Backdrop filter setting.
"fontWeight": "normal", // Font weight setting.
"fontStyle": "normal", // Font style setting.
}, // Secondary setting.
"fontStyle": "normal" // Font style setting.
} // Secondary setting.
}, // Primary and secondary subtitle styling.
// ==========================================
@@ -169,18 +204,20 @@
"url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, // Polling interval in milliseconds.
"proxy": {
"enabled": false, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
"port": 8766, // Bind port for local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
"upstreamUrl": "http://127.0.0.1:8765" // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
}, // Proxy setting.
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"tags": [
"SubMiner"
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": {
"audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText", // Translation setting.
"translation": "SelectionText" // Translation setting.
}, // Fields setting.
"ai": {
"enabled": false, // Enabled setting. Values: true | false
@@ -189,7 +226,7 @@
"model": "openai/gpt-4o-mini", // Model setting.
"baseUrl": "https://openrouter.ai/api", // Base url setting.
"targetLanguage": "English", // Target language setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting.
}, // Ai setting.
"media": {
"generateAudio": true, // Generate audio setting. Values: true | false
@@ -202,7 +239,7 @@
"animatedCrf": 35, // Animated crf setting.
"audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30, // Max media duration setting.
"maxMediaDuration": 30 // Max media duration setting.
}, // Media setting.
"behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
@@ -210,7 +247,7 @@
"mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting.
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -219,20 +256,20 @@
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
"knownWord": "#a6da95" // Color used for legacy known-word highlights.
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enabled setting. Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
}, // Is kiku setting.
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
} // Is kiku setting.
}, // Automatic Anki updates and media generation options.
// ==========================================
@@ -242,7 +279,7 @@
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10, // Maximum Jimaku search results returned.
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
// ==========================================
@@ -253,16 +290,33 @@
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
"whisperModel": "", // Path to whisper model used for fallback transcription.
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
"primarySubLanguages": [
"ja",
"jpn"
] // Comma-separated primary subtitle language priority used by the launcher.
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
// ==========================================
// Anilist
// Anilist API credentials and update behavior.
// Includes optional auto-sync for a merged MRU-based character dictionary in bundled Yomitan.
// Character dictionaries are keyed by AniList media ID (no season/franchise merge).
// ==========================================
"anilist": {
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
"characterDictionary": {
"enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false
"refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.
"maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.
"evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete
"profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active
"collapsibleSections": {
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
"voicedBy": false // Open the Voiced by section by default in character dictionary glossary entries. Values: true | false
} // Collapsible sections setting.
} // Character dictionary setting.
}, // Anilist API credentials and update behavior.
// ==========================================
@@ -286,8 +340,16 @@
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
"directPlayContainers": [
"mkv",
"mp4",
"webm",
"mov",
"flac",
"mp3",
"aac"
], // Container allowlist for direct play decisions.
"transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable.
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
// ==========================================
@@ -298,7 +360,7 @@
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
// ==========================================
@@ -320,7 +382,7 @@
"telemetryDays": 30, // Telemetry retention window in days.
"dailyRollupsDays": 365, // Daily rollup retention window in days.
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
}, // Retention setting.
}, // Enable/disable immersion tracking.
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
} // Retention setting.
} // Enable/disable immersion tracking.
}

View File

@@ -1,4 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { fail, log } from '../log.js';
import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js';
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
@@ -13,6 +15,11 @@ import {
import { generateYoutubeSubtitles } from '../youtube.js';
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js';
import { getDefaultConfigDir, getSetupStatePath, readSetupState } from '../../src/shared/setup-state.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
function checkDependencies(args: Args): void {
const missing: string[] = [];
@@ -84,12 +91,47 @@ function registerCleanup(context: LauncherCommandContext): void {
});
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
const { args, appPath } = context;
if (!appPath) return;
const configDir = getDefaultConfigDir({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
});
const statePath = getSetupStatePath(configDir);
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {
setupArgs.push('--log-level', args.logLevel);
}
const child = spawn(appPath, setupArgs, {
detached: true,
stdio: 'ignore',
});
child.unref();
},
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => Date.now(),
timeoutMs: SETUP_WAIT_TIMEOUT_MS,
pollIntervalMs: SETUP_POLL_INTERVAL_MS,
});
if (!ready) {
fail('SubMiner setup is incomplete. Complete setup in the app, then retry playback.');
}
}
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
}
await ensurePlaybackSetupReady(context);
if (!args.target) {
checkPickerDependencies(args);
}

107
launcher/setup-gate.test.ts Normal file
View File

@@ -0,0 +1,107 @@
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';
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 1,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
},
{
version: 1,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null,
},
];
const result = await waitForSetupCompletion({
readSetupState: () => sequence.shift() ?? null,
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, 'completed');
});
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
const calls: string[] = [];
let reads = 0;
const ready = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
if (reads === 1) return null;
if (reads === 2) {
return {
version: 1,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
};
}
return {
version: 1,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
};
},
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, ['launch']);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 1,
status: 'cancelled',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, false);
});

41
launcher/setup-gate.ts Normal file
View File

@@ -0,0 +1,41 @@
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
export async function waitForSetupCompletion(deps: {
readSetupState: () => SetupState | null;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<'completed' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (isSetupCompleted(state)) {
return 'completed';
}
if (state?.status === 'cancelled') {
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
}
return 'timeout';
}
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
}): Promise<boolean> {
if (isSetupCompleted(deps.readSetupState())) {
return true;
}
deps.launchSetupApp();
const result = await waitForSetupCompletion(deps);
return result === 'completed';
}

View File

@@ -15,13 +15,13 @@
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts",
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js",
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
@@ -128,6 +128,14 @@
"from": "assets",
"to": "assets"
},
{
"from": "plugin/subminer",
"to": "plugin/subminer"
},
{
"from": "plugin/subminer.conf",
"to": "plugin/subminer.conf"
},
{
"from": "dist/scripts/get-mpv-window-macos",
"to": "scripts/get-mpv-window-macos"

View File

@@ -29,12 +29,38 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
YOMITAN_DIR="${1:-$SCRIPT_DIR/../vendor/yomitan}"
YOMITAN_MANIFEST_PATH="$YOMITAN_DIR/manifest.json"
if [ ! -d "$YOMITAN_DIR" ]; then
echo "Error: Yomitan directory not found: $YOMITAN_DIR"
exit 1
fi
if [ ! -f "$YOMITAN_MANIFEST_PATH" ]; then
echo "Error: manifest.json not found at $YOMITAN_MANIFEST_PATH"
exit 1
fi
echo "Patching manifest.json..."
if node - "$YOMITAN_MANIFEST_PATH" <<'PATCH_EOF'
const fs = require('node:fs');
const path = process.argv[2];
const manifest = JSON.parse(fs.readFileSync(path, 'utf8'));
const stableKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxclvOy2sunfRa2UeSV/L9xyuMR9V65z85mbKCy0XvSLUkTBXM8BnvnrDu1DHhLjpidA3cBtetVt7rzwsJSA6/CzlMmtG6L6//3MOAH5Mhng8tXXWXbuNuJobLv/7MORPqoqYKZuoL1bnUvjdrf4Pb3BBDZtHN8LcDz13gOO4dnEFQbSE4F5RQ4mIQAGMkmbmlJkwFk5I022XyX+cWm/+9VvwPuEDA1Qf7X1G+4use3hGYWVPcRb6xTp7swXsO/fP7auE51gYQD0Ht36wr32UR6lfRmsahbHOX4RLe36S8B4ee74kk5C8iCsZf2fidWmevzLk7kK0GW15pv3dpGFpPQIDAQAB';
if (manifest.key === stableKey) {
process.exit(0);
}
manifest.key = stableKey;
fs.writeFileSync(path, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8');
process.exit(0);
PATCH_EOF
then
echo " - Set stable manifest key in manifest.json"
else
echo " - Failed to patch manifest.json"
exit 1
fi
echo "Patching Yomitan in: $YOMITAN_DIR"
PERMISSIONS_UTIL="$YOMITAN_DIR/js/data/permissions-util.js"

View File

@@ -169,4 +169,9 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(background.background, true);
assert.equal(hasExplicitCommand(background), true);
assert.equal(shouldStartApp(background), true);
const setup = parseArgs(['--setup']);
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
assert.equal(hasExplicitCommand(setup), true);
assert.equal(shouldStartApp(setup), true);
});

View File

@@ -5,6 +5,7 @@ export interface CliArgs {
toggle: boolean;
toggleVisibleOverlay: boolean;
settings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
showVisibleOverlay: boolean;
@@ -71,6 +72,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,
@@ -125,6 +127,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle') args.toggle = true;
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
else if (arg === '--show-visible-overlay') args.showVisibleOverlay = true;
@@ -298,6 +301,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.setup ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -341,6 +345,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
@@ -371,6 +376,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.show &&
!args.hide &&
!args.setup &&
!args.showVisibleOverlay &&
!args.hideVisibleOverlay &&
!args.copySubtitle &&

View File

@@ -18,6 +18,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--help\s+Show this help/);
assert.match(output, /default: 7777/);
assert.match(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);

View File

@@ -20,6 +20,7 @@ ${B}Overlay${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
${B}Mining${R}

View File

@@ -16,6 +16,9 @@ test('loads defaults when config is missing', () => {
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.annotationWebsocket.enabled, DEFAULT_CONFIG.annotationWebsocket.enabled);
assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port);
assert.equal(config.texthooker.launchAtStartup, true);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.anilist.enabled, false);
@@ -24,6 +27,9 @@ test('loads defaults when config is missing', () => {
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
@@ -128,6 +134,94 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
);
});
test('parses texthooker.launchAtStartup and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"texthooker": {
"launchAtStartup": false
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().texthooker.launchAtStartup, false);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"texthooker": {
"launchAtStartup": "yes"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().texthooker.launchAtStartup,
DEFAULT_CONFIG.texthooker.launchAtStartup,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'texthooker.launchAtStartup'),
);
});
test('parses annotationWebsocket settings and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"annotationWebsocket": {
"enabled": false,
"port": 7788
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().annotationWebsocket.enabled, false);
assert.equal(validService.getConfig().annotationWebsocket.port, 7788);
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"annotationWebsocket": {
"enabled": "yes",
"port": "bad"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
assert.equal(
invalidService.getConfig().annotationWebsocket.enabled,
DEFAULT_CONFIG.annotationWebsocket.enabled,
);
assert.equal(
invalidService.getConfig().annotationWebsocket.port,
DEFAULT_CONFIG.annotationWebsocket.port,
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'annotationWebsocket.enabled'),
);
assert.ok(
invalidService
.getWarnings()
.some((warning) => warning.path === 'annotationWebsocket.port'),
);
});
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
@@ -417,6 +511,39 @@ test('parses anilist.characterDictionary config with clamping and enum validatio
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'));
});
test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"anilist": {
"characterDictionary": {
"collapsibleSections": {
"description": true,
"characterInformation": "yes",
"voicedBy": true
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true);
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true);
assert.ok(
warnings.some(
(warning) =>
warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation',
),
);
});
test('parses jellyfin remote control fields', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -840,6 +967,10 @@ test('warning emission order is deterministic across reloads', () => {
"enabled": "sometimes",
"port": -1
},
"annotationWebsocket": {
"enabled": "sometimes",
"port": -1
},
"logging": {
"level": "trace"
}
@@ -856,7 +987,14 @@ test('warning emission order is deterministic across reloads', () => {
assert.deepEqual(secondWarnings, firstWarnings);
assert.deepEqual(
firstWarnings.map((warning) => warning.path),
['unknownFeature', 'websocket.enabled', 'websocket.port', 'logging.level'],
[
'unknownFeature',
'websocket.enabled',
'websocket.port',
'annotationWebsocket.enabled',
'annotationWebsocket.port',
'logging.level',
],
);
});
@@ -1426,8 +1564,17 @@ test('template generator includes known keys', () => {
output,
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
);
assert.match(
output,
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(
output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
);
assert.match(
output,
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
);
});

View File

@@ -22,6 +22,7 @@ const {
subtitlePosition,
keybindings,
websocket,
annotationWebsocket,
logging,
texthooker,
shortcuts,
@@ -39,6 +40,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitlePosition,
keybindings,
websocket,
annotationWebsocket,
logging,
texthooker,
ankiConnect,

View File

@@ -5,6 +5,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
| 'annotationWebsocket'
| 'logging'
| 'texthooker'
| 'shortcuts'
@@ -19,10 +20,15 @@ export const CORE_DEFAULT_CONFIG: Pick<
enabled: 'auto',
port: 6677,
},
annotationWebsocket: {
enabled: true,
port: 6678,
},
logging: {
level: 'info',
},
texthooker: {
launchAtStartup: true,
openBrowser: true,
},
shortcuts: {

View File

@@ -92,6 +92,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
maxLoaded: 3,
evictionPolicy: 'delete',
profileScope: 'all',
collapsibleSections: {
description: false,
characterInformation: false,
voicedBy: false,
},
},
},
jellyfin: {

View File

@@ -18,11 +18,13 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [
'logging.level',
'annotationWebsocket.enabled',
'startupWarmups.lowPowerMode',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.collapsibleSections.description',
'immersionTracking.enabled',
]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
@@ -35,6 +37,7 @@ test('config template sections include expected domains and unique keys', () =>
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
const requiredKeys: (typeof keys)[number][] = [
'websocket',
'annotationWebsocket',
'startupWarmups',
'subtitleStyle',
'ankiConnect',

View File

@@ -12,6 +12,12 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'texthooker.launchAtStartup',
kind: 'boolean',
defaultValue: defaultConfig.texthooker.launchAtStartup,
description: 'Launch texthooker server automatically when SubMiner starts.',
},
{
path: 'websocket.enabled',
kind: 'enum',
@@ -25,6 +31,18 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.websocket.port,
description: 'Built-in subtitle websocket server port.',
},
{
path: 'annotationWebsocket.enabled',
kind: 'boolean',
defaultValue: defaultConfig.annotationWebsocket.enabled,
description: 'Annotated subtitle websocket server enabled state.',
},
{
path: 'annotationWebsocket.port',
kind: 'number',
defaultValue: defaultConfig.annotationWebsocket.port,
description: 'Annotated subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',

View File

@@ -171,6 +171,26 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
description: 'Yomitan profile scope for dictionary enable/disable updates.',
},
{
path: 'anilist.characterDictionary.collapsibleSections.description',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.description,
description: 'Open the Description section by default in character dictionary glossary entries.',
},
{
path: 'anilist.characterDictionary.collapsibleSections.characterInformation',
kind: 'boolean',
defaultValue:
defaultConfig.anilist.characterDictionary.collapsibleSections.characterInformation,
description:
'Open the Character Information section by default in character dictionary glossary entries.',
},
{
path: 'anilist.characterDictionary.collapsibleSections.voicedBy',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.voicedBy,
description: 'Open the Voiced by section by default in character dictionary glossary entries.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',

View File

@@ -10,7 +10,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
},
{
title: 'Texthooker Server',
description: ['Control whether browser opens automatically for texthooker.'],
description: ['Configure texthooker startup launch and browser opening behavior.'],
key: 'texthooker',
},
{
@@ -21,6 +21,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'websocket',
},
{
title: 'Annotation WebSocket',
description: [
'Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.',
'Independent from websocket.auto and defaults to port 6678.',
],
key: 'annotationWebsocket',
},
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],

View File

@@ -5,6 +5,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (isObject(src.texthooker)) {
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
if (launchAtStartup !== undefined) {
resolved.texthooker.launchAtStartup = launchAtStartup;
} else if (src.texthooker.launchAtStartup !== undefined) {
warn(
'texthooker.launchAtStartup',
src.texthooker.launchAtStartup,
resolved.texthooker.launchAtStartup,
'Expected boolean.',
);
}
const openBrowser = asBoolean(src.texthooker.openBrowser);
if (openBrowser !== undefined) {
resolved.texthooker.openBrowser = openBrowser;
@@ -44,6 +56,32 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.annotationWebsocket)) {
const enabled = asBoolean(src.annotationWebsocket.enabled);
if (enabled !== undefined) {
resolved.annotationWebsocket.enabled = enabled;
} else if (src.annotationWebsocket.enabled !== undefined) {
warn(
'annotationWebsocket.enabled',
src.annotationWebsocket.enabled,
resolved.annotationWebsocket.enabled,
'Expected boolean.',
);
}
const port = asNumber(src.annotationWebsocket.port);
if (port !== undefined && port > 0 && port <= 65535) {
resolved.annotationWebsocket.port = Math.floor(port);
} else if (src.annotationWebsocket.port !== undefined) {
warn(
'annotationWebsocket.port',
src.annotationWebsocket.port,
resolved.annotationWebsocket.port,
'Expected integer between 1 and 65535.',
);
}
}
if (isObject(src.logging)) {
const logLevel = asString(src.logging.level);
if (

View File

@@ -124,6 +124,31 @@ export function applyIntegrationConfig(context: ResolveContext): void {
'Expected string.',
);
}
if (isObject(characterDictionary.collapsibleSections)) {
const collapsibleSections = characterDictionary.collapsibleSections;
const keys = ['description', 'characterInformation', 'voicedBy'] as const;
for (const key of keys) {
const value = asBoolean(collapsibleSections[key]);
if (value !== undefined) {
resolved.anilist.characterDictionary.collapsibleSections[key] = value;
} else if (collapsibleSections[key] !== undefined) {
warn(
`anilist.characterDictionary.collapsibleSections.${key}`,
collapsibleSections[key],
resolved.anilist.characterDictionary.collapsibleSections[key],
'Expected boolean.',
);
}
}
} else if (characterDictionary.collapsibleSections !== undefined) {
warn(
'anilist.characterDictionary.collapsibleSections',
characterDictionary.collapsibleSections,
resolved.anilist.characterDictionary.collapsibleSections,
'Expected object.',
);
}
} else if (src.anilist.characterDictionary !== undefined) {
warn(
'anilist.characterDictionary',

View File

@@ -72,6 +72,11 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
maxLoaded: 99,
evictionPolicy: 'purge' as never,
profileScope: 'global' as never,
collapsibleSections: {
description: true,
characterInformation: 'invalid' as never,
voicedBy: true,
} as never,
},
},
});
@@ -83,10 +88,19 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all');
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.description, true);
assert.equal(
context.resolved.anilist.characterDictionary.collapsibleSections.characterInformation,
false,
);
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.voicedBy, true);
const warnedPaths = warnings.map((warning) => warning.path);
assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy'));
assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope'));
assert.ok(
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
);
});

View File

@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,

View File

@@ -4,7 +4,8 @@ import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
const calls: string[] = [];
const deps: AppReadyRuntimeDeps = {
const deps = {
ensureDefaultConfigBootstrap: () => calls.push('ensureDefaultConfigBootstrap'),
loadSubtitlePosition: () => calls.push('loadSubtitlePosition'),
resolveKeybindings: () => calls.push('resolveKeybindings'),
createMpvClient: () => calls.push('createMpvClient'),
@@ -20,8 +21,13 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
setSecondarySubMode: (mode) => calls.push(`setSecondarySubMode:${mode}`),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 9001,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => true,
startSubtitleWebsocket: (port) => calls.push(`startSubtitleWebsocket:${port}`),
startAnnotationWebsocket: (port) => calls.push(`startAnnotationWebsocket:${port}`),
startTexthooker: (port, websocketUrl) =>
calls.push(`startTexthooker:${port}:${websocketUrl ?? ''}`),
log: (message) => calls.push(`log:${message}`),
createMecabTokenizerAndCheck: async () => {
calls.push('createMecabTokenizerAndCheck');
@@ -34,6 +40,9 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension');
},
handleFirstRunSetup: async () => {
calls.push('handleFirstRunSetup');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarmSubtitleDictionaries');
},
@@ -48,7 +57,7 @@ function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
logDebug: (message) => calls.push(`debug:${message}`),
now: () => 1000,
...overrides,
};
} as AppReadyRuntimeDeps;
return { deps, calls };
}
@@ -57,7 +66,9 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
hasMpvWebsocketPlugin: () => false,
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('ensureDefaultConfigBootstrap'));
assert.ok(calls.includes('startSubtitleWebsocket:9001'));
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
assert.ok(calls.includes('setVisibleOverlayVisible:true'));
assert.ok(calls.includes('initializeOverlayRuntime'));
assert.ok(
@@ -71,6 +82,47 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy
);
});
test('runAppReadyRuntime starts texthooker on startup when enabled in config', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
secondarySub: {},
texthooker: { launchAtStartup: true },
}),
});
await runAppReadyRuntime(deps);
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
assert.ok(
calls.indexOf('createMpvClient') < calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678'),
);
assert.ok(
calls.indexOf('startTexthooker:5174:ws://127.0.0.1:6678') <
calls.indexOf('handleInitialArgs'),
);
});
test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => {
const { deps, calls } = makeDeps({
getResolvedConfig: () => ({
websocket: { enabled: 'auto' },
annotationWebsocket: { enabled: true, port: 6678 },
secondarySub: {},
texthooker: { launchAtStartup: true },
}),
hasMpvWebsocketPlugin: () => true,
});
await runAppReadyRuntime(deps);
assert.equal(calls.includes('startSubtitleWebsocket:9001'), false);
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
assert.ok(calls.includes('log:mpv_websocket detected, skipping built-in WebSocket server'));
});
test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns true', async () => {
const { deps, calls } = makeDeps({
shouldSkipHeavyStartup: () => true,
@@ -102,6 +154,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
await runAppReadyRuntime(deps);
assert.equal(calls.includes('ensureDefaultConfigBootstrap'), true);
assert.equal(calls.includes('reloadConfig'), false);
assert.equal(calls.includes('getResolvedConfig'), false);
assert.equal(calls.includes('getConfigWarnings'), false);
@@ -116,7 +169,10 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.equal(calls.includes('logConfigWarning'), false);
assert.equal(calls.includes('handleInitialArgs'), true);
assert.equal(calls.includes('loadYomitanExtension'), true);
assert.equal(calls.includes('handleFirstRunSetup'), true);
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleInitialArgs'));
assert.ok(calls.indexOf('loadYomitanExtension') < calls.indexOf('handleFirstRunSetup'));
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,
@@ -96,6 +97,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openFirstRunSetup: () => {
calls.push('openFirstRunSetup');
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
},
@@ -229,6 +233,16 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand opens first-run setup window for --setup', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
test('handleCliCommand applies cli log level for second-instance commands', () => {
const { deps, calls } = createDeps({
setLogLevel: (level) => {

View File

@@ -17,6 +17,7 @@ export interface CliCommandServiceDeps {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -115,6 +116,7 @@ interface MiningCliRuntime {
}
interface UiCliRuntime {
openFirstRunSetup: () => void;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -195,6 +197,7 @@ export function createCliCommandDepsRuntime(
isOverlayRuntimeInitialized: options.overlay.isInitialized,
initializeOverlayRuntime: options.overlay.initialize,
toggleVisibleOverlay: options.overlay.toggleVisible,
openFirstRunSetup: options.ui.openFirstRunSetup,
openYomitanSettingsDelayed: (delayMs) => {
options.schedule(() => {
options.ui.openYomitanSettings();
@@ -298,6 +301,9 @@ export function handleCliCommand(
if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {

View File

@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,

View File

@@ -69,6 +69,13 @@ export function runStartupBootstrapRuntime(
}
interface AppReadyConfigLike {
annotationWebsocket?: {
enabled?: boolean;
port?: number;
};
texthooker?: {
launchAtStartup?: boolean;
};
secondarySub?: {
defaultMode?: SecondarySubMode;
};
@@ -92,6 +99,7 @@ interface AppReadyConfigLike {
}
export interface AppReadyRuntimeDeps {
ensureDefaultConfigBootstrap: () => void;
loadSubtitlePosition: () => void;
resolveKeybindings: () => void;
createMpvClient: () => void;
@@ -104,14 +112,19 @@ export interface AppReadyRuntimeDeps {
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
defaultTexthookerPort: number;
hasMpvWebsocketPlugin: () => boolean;
startSubtitleWebsocket: (port: number) => void;
startAnnotationWebsocket: (port: number) => void;
startTexthooker: (port: number, websocketUrl?: string) => void;
log: (message: string) => void;
createMecabTokenizerAndCheck: () => Promise<void>;
createSubtitleTimingTracker: () => void;
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
texthookerOnlyMode: boolean;
@@ -169,8 +182,10 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.ensureDefaultConfigBootstrap();
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
@@ -179,6 +194,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
return;
@@ -210,6 +226,11 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || deps.defaultWebsocketPort;
const annotationWsConfig = config.annotationWebsocket || {};
const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
const texthookerPort = deps.defaultTexthookerPort;
let texthookerWebsocketUrl: string | undefined;
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort);
@@ -217,6 +238,17 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.log('mpv_websocket detected, skipping built-in WebSocket server');
}
if (annotationWsEnabled) {
deps.startAnnotationWebsocket(annotationWsPort);
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
}
if (config.texthooker?.launchAtStartup !== false) {
deps.startTexthooker(texthookerPort, texthookerWebsocketUrl);
}
deps.createSubtitleTimingTracker();
if (deps.createImmersionTracker) {
deps.log('Runtime ready: immersion tracker startup deferred until first media activity.');
@@ -233,6 +265,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
}
await deps.loadYomitanExtension();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}

View File

@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { serializeSubtitleMarkup, serializeSubtitleWebsocketMessage } from './subtitle-ws';
import {
serializeInitialSubtitleWebsocketMessage,
serializeSubtitleMarkup,
serializeSubtitleWebsocketMessage,
} from './subtitle-ws';
import { PartOfSpeech, type SubtitleData } from '../../types';
const frequencyOptions = {
@@ -78,6 +82,51 @@ test('serializeSubtitleMarkup includes known, n+1, jlpt, and frequency classes',
assert.match(markup, /word word-frequency-band-1/);
});
test('serializeSubtitleMarkup preserves tooltip attrs and name-match precedence', () => {
const payload: SubtitleData = {
text: 'ignored',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
{
surface: 'アレクシア',
reading: 'あれくしあ',
headword: 'アレクシア',
startPos: 2,
endPos: 7,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
isNameMatch: true,
frequencyRank: 12,
},
],
};
const markup = serializeSubtitleMarkup(payload, frequencyOptions);
assert.match(
markup,
/<span class="word word-known word-jlpt-n2" data-reading="ぶじ" data-headword="無事" data-frequency-rank="745" data-jlpt-level="N2">無事<\/span>/,
);
assert.match(
markup,
/<span class="word word-name-match" data-reading="あれくしあ" data-headword="アレクシア" data-frequency-rank="12">アレクシア<\/span>/,
);
assert.doesNotMatch(markup, /word-name-match word-known|word-known word-name-match/);
});
test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
const payload: SubtitleData = {
text: '字幕',
@@ -85,5 +134,101 @@ test('serializeSubtitleWebsocketMessage emits sentence payload', () => {
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
assert.deepEqual(JSON.parse(raw), { sentence: '字幕' });
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '字幕',
sentence: '字幕',
tokens: [],
});
});
test('serializeSubtitleWebsocketMessage emits structured token api payload', () => {
const payload: SubtitleData = {
text: '無事',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
jlptLevel: 'N2',
frequencyRank: 745,
},
],
};
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions);
assert.deepEqual(JSON.parse(raw), {
version: 1,
text: '無事',
sentence:
'<span class="word word-known word-jlpt-n2" data-reading="ぶじ" data-headword="無事" data-frequency-rank="745" data-jlpt-level="N2">無事</span>',
tokens: [
{
surface: '無事',
reading: 'ぶじ',
headword: '無事',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
isNameMatch: false,
jlptLevel: 'N2',
frequencyRank: 745,
className: 'word word-known word-jlpt-n2',
frequencyRankLabel: '745',
jlptLevelLabel: 'N2',
},
],
});
});
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
const payload: SubtitleData = {
text: 'ignored fallback',
tokens: [
{
surface: '既知',
reading: '',
headword: '',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
},
],
};
const raw = serializeInitialSubtitleWebsocketMessage(payload, frequencyOptions);
assert.deepEqual(JSON.parse(raw ?? ''), {
version: 1,
text: 'ignored fallback',
sentence: '<span class="word word-known">既知</span>',
tokens: [
{
surface: '既知',
reading: '',
headword: '',
startPos: 0,
endPos: 2,
partOfSpeech: PartOfSpeech.other,
isMerged: false,
isKnown: true,
isNPlusOneTarget: false,
isNameMatch: false,
className: 'word word-known',
frequencyRankLabel: null,
jlptLevelLabel: null,
},
],
});
});

View File

@@ -18,6 +18,26 @@ export type SubtitleWebsocketFrequencyOptions = {
mode: 'single' | 'banded';
};
type SerializedSubtitleToken = Pick<
MergedToken,
| 'surface'
| 'reading'
| 'headword'
| 'startPos'
| 'endPos'
| 'partOfSpeech'
| 'isMerged'
| 'isKnown'
| 'isNPlusOneTarget'
| 'frequencyRank'
| 'jlptLevel'
> & {
isNameMatch: boolean;
className: string;
frequencyRankLabel: string | null;
jlptLevelLabel: string | null;
};
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
@@ -46,11 +66,29 @@ function computeFrequencyClass(
return 'word-frequency-single';
}
function getFrequencyRankLabel(
token: MergedToken,
options: SubtitleWebsocketFrequencyOptions,
): string | null {
if (!options.enabled) return null;
if (typeof token.frequencyRank !== 'number' || !Number.isFinite(token.frequencyRank)) return null;
const rank = Math.max(1, Math.floor(token.frequencyRank));
const topX = Math.max(1, Math.floor(options.topX));
return rank <= topX ? String(rank) : null;
}
function getJlptLevelLabel(token: MergedToken): string | null {
return token.jlptLevel ?? null;
}
function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequencyOptions): string {
const classes = ['word'];
if (token.isNPlusOneTarget) {
classes.push('word-n-plus-one');
} else if (token.isNameMatch) {
classes.push('word-name-match');
} else if (token.isKnown) {
classes.push('word-known');
}
@@ -59,7 +97,7 @@ function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequenc
classes.push(`word-jlpt-${token.jlptLevel.toLowerCase()}`);
}
if (!token.isKnown && !token.isNPlusOneTarget) {
if (!token.isKnown && !token.isNPlusOneTarget && !token.isNameMatch) {
const frequencyClass = computeFrequencyClass(token, options);
if (frequencyClass) {
classes.push(frequencyClass);
@@ -69,6 +107,55 @@ function computeWordClass(token: MergedToken, options: SubtitleWebsocketFrequenc
return classes.join(' ');
}
function serializeWordDataAttributes(
token: MergedToken,
options: SubtitleWebsocketFrequencyOptions,
): string {
const attributes: string[] = [];
if (token.reading) {
attributes.push(`data-reading="${escapeHtml(token.reading)}"`);
}
if (token.headword) {
attributes.push(`data-headword="${escapeHtml(token.headword)}"`);
}
const frequencyRankLabel = getFrequencyRankLabel(token, options);
if (frequencyRankLabel) {
attributes.push(`data-frequency-rank="${escapeHtml(frequencyRankLabel)}"`);
}
const jlptLevelLabel = getJlptLevelLabel(token);
if (jlptLevelLabel) {
attributes.push(`data-jlpt-level="${escapeHtml(jlptLevelLabel)}"`);
}
return attributes.length > 0 ? ` ${attributes.join(' ')}` : '';
}
function serializeSubtitleToken(
token: MergedToken,
options: SubtitleWebsocketFrequencyOptions,
): SerializedSubtitleToken {
return {
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
partOfSpeech: token.partOfSpeech,
isMerged: token.isMerged,
isKnown: token.isKnown,
isNPlusOneTarget: token.isNPlusOneTarget,
isNameMatch: token.isNameMatch ?? false,
jlptLevel: token.jlptLevel,
frequencyRank: token.frequencyRank,
className: computeWordClass(token, options),
frequencyRankLabel: getFrequencyRankLabel(token, options),
jlptLevelLabel: getJlptLevelLabel(token),
};
}
export function serializeSubtitleMarkup(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
@@ -80,11 +167,12 @@ export function serializeSubtitleMarkup(
const chunks: string[] = [];
for (const token of payload.tokens) {
const klass = computeWordClass(token, options);
const attrs = serializeWordDataAttributes(token, options);
const parts = token.surface.split('\n');
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
if (part) {
chunks.push(`<span class="${klass}">${escapeHtml(part)}</span>`);
chunks.push(`<span class="${klass}"${attrs}>${escapeHtml(part)}</span>`);
}
if (index < parts.length - 1) {
chunks.push('<br>');
@@ -99,7 +187,23 @@ export function serializeSubtitleWebsocketMessage(
payload: SubtitleData,
options: SubtitleWebsocketFrequencyOptions,
): string {
return JSON.stringify({ sentence: serializeSubtitleMarkup(payload, options) });
return JSON.stringify({
version: 1,
text: payload.text,
sentence: serializeSubtitleMarkup(payload, options),
tokens: payload.tokens?.map((token) => serializeSubtitleToken(token, options)) ?? [],
});
}
export function serializeInitialSubtitleWebsocketMessage(
payload: SubtitleData | null,
options: SubtitleWebsocketFrequencyOptions,
): string | null {
if (!payload || !payload.text.trim()) {
return null;
}
return serializeSubtitleWebsocketMessage(payload, options);
}
export class SubtitleWebSocket {
@@ -114,7 +218,11 @@ export class SubtitleWebSocket {
return (this.server?.clients.size ?? 0) > 0;
}
public start(port: number, getCurrentSubtitleText: () => string): void {
public start(
port: number,
getCurrentSubtitleData: () => SubtitleData | null,
getFrequencyOptions: () => SubtitleWebsocketFrequencyOptions,
): void {
this.server = new WebSocket.Server({ port, host: '127.0.0.1' });
this.server.on('connection', (ws: WebSocket) => {
@@ -124,9 +232,12 @@ export class SubtitleWebSocket {
return;
}
const currentText = getCurrentSubtitleText();
if (currentText) {
ws.send(JSON.stringify({ sentence: currentText }));
const currentMessage = serializeInitialSubtitleWebsocketMessage(
getCurrentSubtitleData(),
getFrequencyOptions(),
);
if (currentMessage) {
ws.send(currentMessage);
}
});

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { injectTexthookerBootstrapHtml } from './texthooker';
test('injectTexthookerBootstrapHtml injects websocket bootstrap before head close', () => {
const html = '<html><head><title>Texthooker</title></head><body></body></html>';
const actual = injectTexthookerBootstrapHtml(html, 'ws://127.0.0.1:6678');
assert.match(
actual,
/window\.localStorage\.setItem\('bannou-texthooker-websocketUrl', "ws:\/\/127\.0\.0\.1:6678"\)/,
);
assert.ok(actual.indexOf('</script></head>') !== -1);
assert.ok(actual.includes("bannou-texthooker-websocketUrl"));
assert.ok(!actual.includes('bannou-texthooker-enableKnownWordColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableNPlusOneColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableNameMatchColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableFrequencyColoring'));
assert.ok(!actual.includes('bannou-texthooker-enableJlptColoring'));
});
test('injectTexthookerBootstrapHtml leaves html unchanged without websocketUrl', () => {
const html = '<html><head></head><body></body></html>';
assert.equal(injectTexthookerBootstrapHtml(html), html);
});

View File

@@ -5,6 +5,22 @@ import { createLogger } from '../../logger';
const logger = createLogger('main:texthooker');
export function injectTexthookerBootstrapHtml(html: string, websocketUrl?: string): string {
if (!websocketUrl) {
return html;
}
const bootstrapScript = `<script>window.localStorage.setItem('bannou-texthooker-websocketUrl', ${JSON.stringify(
websocketUrl,
)});</script>`;
if (html.includes('</head>')) {
return html.replace('</head>', `${bootstrapScript}</head>`);
}
return `${bootstrapScript}${html}`;
}
export class Texthooker {
private server: http.Server | null = null;
@@ -12,7 +28,11 @@ export class Texthooker {
return this.server !== null;
}
public start(port: number): http.Server | null {
public start(port: number, websocketUrl?: string): http.Server | null {
if (this.server) {
return this.server;
}
const texthookerPath = this.getTexthookerPath();
if (!texthookerPath) {
logger.error('texthooker-ui not found');
@@ -42,8 +62,12 @@ export class Texthooker {
res.end('Not found');
return;
}
const responseData =
urlPath === '/' || urlPath === '/index.html'
? Buffer.from(injectTexthookerBootstrapHtml(data.toString('utf-8'), websocketUrl))
: data;
res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
res.end(data);
res.end(responseData);
});
});

View File

@@ -1861,9 +1861,9 @@ test('tokenizeSubtitle keeps parsing explicit by scanning-parser source only', a
assert.equal(result.tokens?.[4]?.frequencyRank, 1500);
});
test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', async () => {
test('tokenizeSubtitle still assigns frequency to non-known multi-character Yomitan tokens', async () => {
const result = await tokenizeSubtitle(
'小園',
'小園友達',
makeDeps({
getYomitanExt: () => ({ id: 'dummy-ext' }) as any,
getYomitanParserWindow: () =>
@@ -1884,9 +1884,9 @@ test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', asy
],
[
{
text: '',
reading: '',
headwords: [[{ term: '' }]],
text: '友達',
reading: 'ともだち',
headwords: [[{ term: '友達' }]],
},
],
],
@@ -1895,7 +1895,7 @@ test('tokenizeSubtitle still assigns frequency to non-known Yomitan tokens', asy
},
}) as unknown as Electron.BrowserWindow,
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === '小園' ? 75 : text === '' ? 3000 : null),
getFrequencyRank: (text) => (text === '小園' ? 75 : text === '友達' ? 3000 : null),
isKnownWord: (text) => text === '小園',
}),
);
@@ -2635,6 +2635,21 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
});
test('tokenizeSubtitle excludes single-kana merged tokens from frequency highlighting', async () => {
const result = await tokenizeSubtitle(
'た',
makeDepsFromYomitanTokens([{ surface: 'た', reading: 'た', headword: 'た' }], {
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === 'た' ? 17 : null),
getMinSentenceWordsForNPlusOne: () => 1,
tokenizeWithMecab: async () => null,
}),
);
assert.equal(result.tokens?.length, 1);
assert.equal(result.tokens?.[0]?.frequencyRank, undefined);
});
test('tokenizeSubtitle excludes merged function/content token from frequency highlighting but keeps N+1', async () => {
const result = await tokenizeSubtitle(
'になれば',

View File

@@ -252,12 +252,12 @@ test('annotateTokens applies configured pos1 exclusions to both frequency and N+
test('annotateTokens allows previously default-excluded pos1 when removed from effective set', () => {
const tokens = [
makeToken({
surface: '',
headword: '',
surface: 'まで',
headword: 'まで',
partOfSpeech: PartOfSpeech.other,
pos1: '助詞',
startPos: 0,
endPos: 1,
endPos: 2,
frequencyRank: 8,
}),
];
@@ -314,6 +314,52 @@ test('annotateTokens excludes likely kana SFX tokens from frequency when POS tag
assert.equal(result[0]?.frequencyRank, undefined);
});
test('annotateTokens excludes single hiragana and katakana tokens from frequency when POS tags are missing', () => {
const tokens = [
makeToken({
surface: 'た',
reading: 'た',
headword: 'た',
pos1: '',
pos2: '',
partOfSpeech: PartOfSpeech.other,
frequencyRank: 21,
startPos: 0,
endPos: 1,
}),
makeToken({
surface: 'ア',
reading: 'ア',
headword: 'ア',
pos1: '',
pos2: '',
partOfSpeech: PartOfSpeech.other,
frequencyRank: 22,
startPos: 1,
endPos: 2,
}),
makeToken({
surface: '山',
reading: 'やま',
headword: '山',
pos1: '',
pos2: '',
partOfSpeech: PartOfSpeech.other,
frequencyRank: 23,
startPos: 2,
endPos: 3,
}),
];
const result = annotateTokens(tokens, makeDeps(), {
minSentenceWordsForNPlusOne: 1,
});
assert.equal(result[0]?.frequencyRank, undefined);
assert.equal(result[1]?.frequencyRank, undefined);
assert.equal(result[2]?.frequencyRank, 23);
});
test('annotateTokens keeps frequency when mecab tags classify token as content-bearing', () => {
const tokens = [
makeToken({

View File

@@ -103,6 +103,10 @@ function isFrequencyExcludedByPos(
pos1Exclusions: ReadonlySet<string>,
pos2Exclusions: ReadonlySet<string>,
): boolean {
if (isSingleKanaFrequencyNoiseToken(token.surface)) {
return true;
}
const normalizedPos1 = normalizePos1Tag(token.pos1);
const hasPos1 = normalizedPos1.length > 0;
if (isExcludedByTagSet(normalizedPos1, pos1Exclusions)) {
@@ -363,6 +367,20 @@ function isLikelyFrequencyNoiseToken(token: MergedToken): boolean {
return false;
}
function isSingleKanaFrequencyNoiseToken(text: string | undefined): boolean {
if (typeof text !== 'string') {
return false;
}
const normalized = text.trim();
if (!normalized) {
return false;
}
const chars = [...normalized];
return chars.length === 1 && isKanaChar(chars[0]!);
}
function isJlptEligibleToken(token: MergedToken): boolean {
if (token.pos1 && shouldIgnoreJlptForMecabPos1(token.pos1)) {
return false;

View File

@@ -643,6 +643,175 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
});
test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => {
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
},
},
],
};
}
return await runInjectedYomitanScript(script, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (text.startsWith('だが ')) {
return {
originalTextLength: 2,
dictionaryEntries: [
{
headwords: [
{
term: 'だが',
reading: 'だが',
sources: [{ originalText: 'だが', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
if (text.startsWith('それでも')) {
return {
originalTextLength: 4,
dictionaryEntries: [
{
headwords: [
{
term: 'それでも',
reading: 'それでも',
sources: [{ originalText: 'それでも', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
if (text.startsWith('届かぬ')) {
return {
originalTextLength: 3,
dictionaryEntries: [
{
headwords: [
{
term: '届く',
reading: 'とどく',
sources: [{ originalText: '届かぬ', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
if (text.startsWith('高み')) {
return {
originalTextLength: 2,
dictionaryEntries: [
{
headwords: [
{
term: '高み',
reading: 'たかみ',
sources: [{ originalText: '高み', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
if (text.startsWith('があった')) {
return {
originalTextLength: 2,
dictionaryEntries: [
{
headwords: [
{
term: 'があ',
reading: '',
sources: [{ originalText: 'が', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
if (text.startsWith('あった')) {
return {
originalTextLength: 3,
dictionaryEntries: [
{
headwords: [
{
term: 'ある',
reading: 'ある',
sources: [{ originalText: 'あった', isPrimary: true, matchType: 'exact' }],
},
],
},
],
};
}
return { originalTextLength: 0, dictionaryEntries: [] };
});
});
const result = await requestYomitanScanTokens(
'だが それでも届かぬ高みがあった',
deps,
{ error: () => undefined },
);
assert.deepEqual(
result?.map((token) => ({
surface: token.surface,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
})),
[
{
surface: 'だが',
headword: 'だが',
startPos: 0,
endPos: 2,
},
{
surface: 'それでも',
headword: 'それでも',
startPos: 3,
endPos: 7,
},
{
surface: '届かぬ',
headword: '届く',
startPos: 7,
endPos: 10,
},
{
surface: '高み',
headword: '高み',
startPos: 10,
endPos: 12,
},
{
surface: 'あった',
headword: 'ある',
startPos: 13,
endPos: 16,
},
],
);
});
test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
let scriptValue = '';
const deps = createDeps(async (script) => {
@@ -656,7 +825,7 @@ test('getYomitanDictionaryInfo requests dictionary info via backend action', asy
assert.match(scriptValue, /getDictionaryInfo/);
});
test('dictionary settings helpers upsert and remove dictionary entries', async () => {
test('dictionary settings helpers upsert and remove dictionary entries without reordering', async () => {
const scripts: string[] = [];
const optionsFull = {
profileCurrent: 0,

View File

@@ -843,14 +843,7 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
};
}
}
const fallback = dictionaryEntries?.[0]?.headwords?.[0];
return fallback
? {
term: fallback.term,
reading: fallback.reading,
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(dictionaryEntries?.[0])
}
: null;
return null;
}
`;
@@ -1676,7 +1669,7 @@ export async function upsertYomitanDictionarySettings(
continue;
}
dictionaries.unshift(createDefaultDictionarySettings(normalizedTitle, true));
dictionaries.push(createDefaultDictionarySettings(normalizedTitle, true));
changed = true;
}

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
resolveConfigExampleOutputPaths,
writeConfigExampleArtifacts,
} from './generate-config-example';
function createWorkspace(name: string): string {
const baseDir = path.join(process.cwd(), '.tmp', 'generate-config-example-test');
fs.mkdirSync(baseDir, { recursive: true });
return fs.mkdtempSync(path.join(baseDir, `${name}-`));
}
test('resolveConfigExampleOutputPaths includes sibling docs repo and never local docs/public', () => {
const workspace = createWorkspace('with-docs-repo');
const projectRoot = path.join(workspace, 'SubMiner');
const docsRepoRoot = path.join(workspace, 'subminer-docs');
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(docsRepoRoot, { recursive: true });
try {
const outputPaths = resolveConfigExampleOutputPaths({ cwd: projectRoot });
assert.deepEqual(outputPaths, [
path.join(projectRoot, 'config.example.jsonc'),
path.join(docsRepoRoot, 'public', 'config.example.jsonc'),
]);
assert.equal(
outputPaths.includes(path.join(projectRoot, 'docs', 'public', 'config.example.jsonc')),
false,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('resolveConfigExampleOutputPaths stays repo-local when sibling docs repo is absent', () => {
const workspace = createWorkspace('without-docs-repo');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(projectRoot, { recursive: true });
try {
const outputPaths = resolveConfigExampleOutputPaths({ cwd: projectRoot });
assert.deepEqual(outputPaths, [path.join(projectRoot, 'config.example.jsonc')]);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writeConfigExampleArtifacts creates parent directories for resolved outputs', () => {
const workspace = createWorkspace('write-artifacts');
const projectRoot = path.join(workspace, 'SubMiner');
const docsRepoRoot = path.join(workspace, 'subminer-docs');
const template = '{\n "ok": true\n}\n';
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(docsRepoRoot, { recursive: true });
try {
const writtenPaths = writeConfigExampleArtifacts(template, {
cwd: projectRoot,
deps: { log: () => {} },
});
assert.deepEqual(writtenPaths, [
path.join(projectRoot, 'config.example.jsonc'),
path.join(docsRepoRoot, 'public', 'config.example.jsonc'),
]);
assert.equal(fs.readFileSync(path.join(projectRoot, 'config.example.jsonc'), 'utf8'), template);
assert.equal(
fs.readFileSync(path.join(docsRepoRoot, 'public', 'config.example.jsonc'), 'utf8'),
template,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});

View File

@@ -2,18 +2,62 @@ import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_CONFIG, generateConfigTemplate } from './config';
function main(): void {
const template = generateConfigTemplate(DEFAULT_CONFIG);
const outputPaths = [
path.join(process.cwd(), 'config.example.jsonc'),
path.join(process.cwd(), 'docs', 'public', 'config.example.jsonc'),
];
type ConfigExampleFsDeps = {
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
log?: (message: string) => void;
};
for (const outputPath of outputPaths) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, template, 'utf-8');
console.log(`Generated ${outputPath}`);
export function resolveConfigExampleOutputPaths(options?: {
cwd?: string;
docsRepoName?: string;
existsSync?: (candidate: string) => boolean;
}): string[] {
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.existsSync ?? fs.existsSync;
const docsRepoName = options?.docsRepoName ?? 'subminer-docs';
const outputPaths = [path.join(cwd, 'config.example.jsonc')];
const docsRepoRoot = path.resolve(cwd, '..', docsRepoName);
if (existsSync(docsRepoRoot)) {
outputPaths.push(path.join(docsRepoRoot, 'public', 'config.example.jsonc'));
}
return outputPaths;
}
main();
export function writeConfigExampleArtifacts(
template: string,
options?: {
cwd?: string;
docsRepoName?: string;
deps?: ConfigExampleFsDeps;
},
): string[] {
const mkdirSync = options?.deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
const log = options?.deps?.log ?? console.log;
const outputPaths = resolveConfigExampleOutputPaths({
cwd: options?.cwd,
docsRepoName: options?.docsRepoName,
existsSync: options?.deps?.existsSync,
});
for (const outputPath of outputPaths) {
mkdirSync(path.dirname(outputPath), { recursive: true });
writeFileSync(outputPath, template, 'utf-8');
log(`Generated ${outputPath}`);
}
return outputPaths;
}
function main(): void {
const template = generateConfigTemplate(DEFAULT_CONFIG);
writeConfigExampleArtifacts(template);
}
if (require.main === module) {
main();
}

View File

@@ -1,12 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeStartupArgv,
sanitizeHelpEnv,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
} from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background', () => {
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
'SubMiner.AppImage',
'--start',
'--background',
]);
assert.deepEqual(
normalizeStartupArgv(
['SubMiner.AppImage', '--password-store', 'gnome-libsecret'],
{},
),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
'SubMiner.AppImage',
'--background',
'--start',
]);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
'SubMiner.AppImage',
'--help',
]);
});
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
@@ -14,6 +40,14 @@ test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
});
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeStartupEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeHelpEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',

View File

@@ -1,6 +1,8 @@
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
const BACKGROUND_ARG = '--background';
const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
@@ -9,10 +11,54 @@ function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
}
}
function removePassiveStartupArgs(argv: string[]): string[] {
const filtered: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg) continue;
if (arg === PASSWORD_STORE_ARG) {
const value = argv[i + 1];
if (value && !value.startsWith('--')) {
i += 1;
}
continue;
}
if (arg.startsWith(`${PASSWORD_STORE_ARG}=`)) {
continue;
}
filtered.push(arg);
}
return filtered;
}
function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv);
}
export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): string[] {
if (env.ELECTRON_RUN_AS_NODE === '1') return argv;
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
if (effectiveArgs.length === 0) {
return [...argv, START_ARG, BACKGROUND_ARG];
}
if (
effectiveArgs.length === 1 &&
effectiveArgs[0] === BACKGROUND_ARG &&
!argv.includes(START_ARG)
) {
return [...argv, START_ARG];
}
return argv;
}
export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
if (!argv.includes(BACKGROUND_ARG)) return false;
@@ -26,7 +72,7 @@ export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessE
return args.help && !shouldStartApp(args);
}
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = { ...baseEnv };
if (!env.NODE_NO_WARNINGS) {
env.NODE_NO_WARNINGS = '1';
@@ -35,8 +81,12 @@ export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return env;
}
export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return sanitizeStartupEnv(baseEnv);
}
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env = sanitizeHelpEnv(baseEnv);
const env = sanitizeStartupEnv(baseEnv);
env[BACKGROUND_CHILD_ENV] = '1';
return env;
}

View File

@@ -1,6 +1,8 @@
import { spawn } from 'node:child_process';
import { printHelp } from './cli/help';
import {
normalizeStartupArgv,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
sanitizeHelpEnv,
shouldDetachBackgroundLaunch,
@@ -9,6 +11,21 @@ import {
const DEFAULT_TEXTHOOKER_PORT = 5174;
function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
if (sanitizedEnv.NODE_NO_WARNINGS) {
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
}
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
} else {
delete process.env.VK_INSTANCE_LAYERS;
}
}
process.argv = normalizeStartupArgv(process.argv, process.env);
applySanitizedEnv(sanitizeStartupEnv(process.env));
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), {
detached: true,

View File

@@ -239,6 +239,11 @@ import {
resolveKeybindings,
showDesktopNotification,
} from './core/utils';
import {
ensureDefaultConfigBootstrap,
getDefaultConfigFilePaths,
resolveDefaultMpvInstallPaths,
} from './shared/setup-state';
import {
ImmersionTrackerService,
JellyfinRemoteSessionService,
@@ -296,6 +301,21 @@ import {
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import {
createFirstRunSetupService,
shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service';
import {
buildFirstRunSetupHtml,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
type FirstRunSetupAction,
} from './main/runtime/first-run-setup-window';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
} from './main/runtime/first-run-setup-plugin';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
@@ -375,7 +395,7 @@ if (process.platform === 'linux') {
getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore(),
);
app.commandLine.appendSwitch('password-store', passwordStore);
console.debug(`[main] Applied --password-store ${passwordStore}`);
createLogger('main').debug(`Applied --password-store ${passwordStore}`);
}
app.setName('SubMiner');
@@ -495,6 +515,7 @@ const anilistUpdateQueue = createAnilistUpdateQueue(
const isDev = process.argv.includes('--dev') || process.argv.includes('--debug');
const texthookerService = new Texthooker();
const subtitleWsService = new SubtitleWebSocket();
const annotationSubtitleWsService = new SubtitleWebSocket();
const logger = createLogger('main');
notifyAnilistTokenStoreWarning = (message: string) => {
logger.warn(`[AniList] ${message}`);
@@ -601,6 +622,41 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
let firstRunSetupMessage: string | null = null;
const firstRunSetupService = createFirstRunSetupService({
configDir: CONFIG_DIR,
getYomitanDictionaryCount: async () => {
await ensureYomitanExtensionLoaded();
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args),
});
return dictionaries.length;
},
detectPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
process.env.XDG_CONFIG_HOME,
);
return detectInstalledFirstRunPlugin(installPaths);
},
installPlugin: async () =>
installFirstRunPluginToDefaultLocation({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}),
onStateChanged: (state) => {
appState.firstRunSetupCompleted = state.status === 'completed';
if (appTray) {
ensureTray();
}
},
});
const discordPresenceSessionStartedAtMs = Date.now();
let discordPresenceMediaDurationSec: number | null = null;
@@ -890,6 +946,11 @@ const buildSubtitleProcessingControllerMainDepsHandler =
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
annotationSubtitleWsService.broadcast(payload, {
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
});
},
logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`);
@@ -1147,6 +1208,8 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
getCurrentMediaTitle: () => appState.currentMediaTitle,
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
getCollapsibleSectionOpenState: (section) =>
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
now: () => Date.now(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
@@ -1594,6 +1657,96 @@ const {
},
});
const maybeFocusExistingFirstRunSetupWindow =
createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
});
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: maybeFocusExistingFirstRunSetupWindow,
createSetupWindow: () =>
new BrowserWindow({
width: 480,
height: 460,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
}),
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
canFinish: snapshot.canFinish,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
message: firstRunSetupMessage,
};
},
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
handleAction: async (action: FirstRunSetupAction) => {
if (action === 'install-plugin') {
const snapshot = await firstRunSetupService.installMpvPlugin();
firstRunSetupMessage = snapshot.message;
return;
}
if (action === 'open-yomitan-settings') {
openYomitanSettings();
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
return;
}
if (action === 'refresh') {
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
firstRunSetupMessage = snapshot.message;
return;
}
if (action === 'skip-plugin') {
await firstRunSetupService.skipPluginInstall();
firstRunSetupMessage = 'mpv plugin installation skipped.';
return;
}
const snapshot = await firstRunSetupService.markSetupCompleted();
if (snapshot.state.status === 'completed') {
firstRunSetupMessage = null;
return { closeWindow: true };
}
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
return;
},
markSetupInProgress: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupInProgress();
},
markSetupCancelled: async () => {
firstRunSetupMessage = null;
await firstRunSetupService.markSetupCancelled();
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
},
setSetupWindow: (window) => {
appState.firstRunSetupWindow = window as BrowserWindow;
},
encodeURIComponent: (value) => encodeURIComponent(value),
logError: (message, error) => logger.error(message, error),
});
function openFirstRunSetupWindow(): void {
if (firstRunSetupService.isSetupCompleted()) {
return;
}
openFirstRunSetupWindowHandler();
}
const {
notifyAnilistSetup,
consumeAnilistSetupTokenFromUrl,
@@ -1646,7 +1799,7 @@ const {
appPath
? app.setAsDefaultProtocolClient(scheme, appPath, args)
: app.setAsDefaultProtocolClient(scheme),
logWarn: (message, details) => logger.warn(message, details),
logDebug: (message, details) => logger.debug(message, details),
},
});
@@ -2016,7 +2169,10 @@ const {
restoreOverlayMpvSubtitles();
},
unregisterAllGlobalShortcuts: () => globalShortcut.unregisterAll(),
stopSubtitleWebsocket: () => subtitleWsService.stop(),
stopSubtitleWebsocket: () => {
subtitleWsService.stop();
annotationSubtitleWsService.stop();
},
stopTexthookerService: () => texthookerService.stop(),
getYomitanParserWindow: () => appState.yomitanParserWindow,
clearYomitanParserState: () => {
@@ -2121,6 +2277,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {
ensureDefaultConfigBootstrap({
configDir: CONFIG_DIR,
configFilePaths: getDefaultConfigFilePaths(CONFIG_DIR),
generateTemplate: () => generateConfigTemplate(DEFAULT_CONFIG),
});
},
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
@@ -2155,9 +2318,49 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
subtitleWsService.start(
port,
() =>
appState.currentSubtitleData ??
(appState.currentSubText
? {
text: appState.currentSubText,
tokens: null,
}
: null),
() => ({
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}),
);
},
startAnnotationWebsocket: (port: number) => {
annotationSubtitleWsService.start(
port,
() =>
appState.currentSubtitleData ??
(appState.currentSubText
? {
text: appState.currentSubText,
tokens: null,
}
: null),
() => ({
enabled: getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}),
);
},
startTexthooker: (port: number, websocketUrl?: string) => {
if (!texthookerService.isRunning()) {
texthookerService.start(port, websocketUrl);
}
},
log: (message) => appLogger.logInfo(message),
createMecabTokenizerAndCheck: async () => {
@@ -2170,6 +2373,17 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
if (
appState.initialArgs &&
shouldAutoOpenFirstRunSetup(appState.initialArgs) &&
snapshot.state.status !== 'completed'
) {
openFirstRunSetupWindow();
}
},
startJellyfinRemoteSession: async () => {
await startJellyfinRemoteSession();
},
@@ -2190,7 +2404,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
shouldSkipHeavyStartup: () =>
Boolean(
appState.initialArgs &&
(shouldRunSettingsOnlyStartup(appState.initialArgs) || appState.initialArgs.dictionary),
(shouldRunSettingsOnlyStartup(appState.initialArgs) ||
appState.initialArgs.dictionary ||
appState.initialArgs.setup),
),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
@@ -3090,6 +3306,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
showMpvOsd: (text: string) => showMpvOsd(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
@@ -3161,6 +3378,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
initializeOverlayRuntime: () => initializeOverlayRuntime(),
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
openYomitanSettings: () => openYomitanSettings(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),

View File

@@ -19,6 +19,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
}
export interface AppReadyRuntimeDepsFactoryInput {
ensureDefaultConfigBootstrap: AppReadyRuntimeDeps['ensureDefaultConfigBootstrap'];
loadSubtitlePosition: AppReadyRuntimeDeps['loadSubtitlePosition'];
resolveKeybindings: AppReadyRuntimeDeps['resolveKeybindings'];
createMpvClient: AppReadyRuntimeDeps['createMpvClient'];
@@ -30,8 +31,12 @@ export interface AppReadyRuntimeDepsFactoryInput {
setSecondarySubMode: AppReadyRuntimeDeps['setSecondarySubMode'];
defaultSecondarySubMode: AppReadyRuntimeDeps['defaultSecondarySubMode'];
defaultWebsocketPort: AppReadyRuntimeDeps['defaultWebsocketPort'];
defaultAnnotationWebsocketPort: AppReadyRuntimeDeps['defaultAnnotationWebsocketPort'];
defaultTexthookerPort: AppReadyRuntimeDeps['defaultTexthookerPort'];
hasMpvWebsocketPlugin: AppReadyRuntimeDeps['hasMpvWebsocketPlugin'];
startSubtitleWebsocket: AppReadyRuntimeDeps['startSubtitleWebsocket'];
startAnnotationWebsocket: AppReadyRuntimeDeps['startAnnotationWebsocket'];
startTexthooker: AppReadyRuntimeDeps['startTexthooker'];
log: AppReadyRuntimeDeps['log'];
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
@@ -39,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
texthookerOnlyMode: AppReadyRuntimeDeps['texthookerOnlyMode'];
@@ -75,6 +81,7 @@ export function createAppReadyRuntimeDeps(
params: AppReadyRuntimeDepsFactoryInput,
): AppReadyRuntimeDeps {
return {
ensureDefaultConfigBootstrap: params.ensureDefaultConfigBootstrap,
loadSubtitlePosition: params.loadSubtitlePosition,
resolveKeybindings: params.resolveKeybindings,
createMpvClient: params.createMpvClient,
@@ -86,8 +93,12 @@ export function createAppReadyRuntimeDeps(
setSecondarySubMode: params.setSecondarySubMode,
defaultSecondarySubMode: params.defaultSecondarySubMode,
defaultWebsocketPort: params.defaultWebsocketPort,
defaultAnnotationWebsocketPort: params.defaultAnnotationWebsocketPort,
defaultTexthookerPort: params.defaultTexthookerPort,
hasMpvWebsocketPlugin: params.hasMpvWebsocketPlugin,
startSubtitleWebsocket: params.startSubtitleWebsocket,
startAnnotationWebsocket: params.startAnnotationWebsocket,
startTexthooker: params.startTexthooker,
log: params.log,
setLogLevel: params.setLogLevel,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
@@ -95,6 +106,7 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
texthookerOnlyMode: params.texthookerOnlyMode,

View File

@@ -220,8 +220,9 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
(c as { tag?: string }).tag === 'details' &&
Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content === 'Description',
) as { tag: string; content: Array<Record<string, unknown>> } | undefined;
) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | undefined;
assert.ok(descSection, 'expected Description collapsible section');
assert.equal(descSection.open, false);
const descBody = descSection.content[1] as { content: string };
assert.ok(
descBody.content.includes('Alexia Midgar is the second princess of the Kingdom of Midgar.'),
@@ -233,11 +234,12 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content ===
'Character Information',
) as { tag: string; content: Array<Record<string, unknown>> } | undefined;
) as { tag: string; open?: boolean; content: Array<Record<string, unknown>> } | undefined;
assert.ok(
infoSection,
'expected Character Information collapsible section with parsed __Race:__ field',
);
assert.equal(infoSection.open, false);
const topLevelImageGlossaryEntry = glossary.find(
(item) =>
@@ -249,6 +251,328 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
}
});
test('generateForCurrentMedia applies configured open states to character dictionary sections', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'The Eminence in Shadow',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'The Eminence in Shadow',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'SUPPORTING',
voiceActors: [
{
id: 456,
name: {
full: 'Rina Hidaka',
native: '日高里菜',
},
image: {
medium: 'https://cdn.example.com/va-456.jpg',
},
},
],
node: {
id: 123,
description:
'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human',
image: {
large: 'https://cdn.example.com/character-123.png',
medium: 'https://cdn.example.com/character-123-small.png',
},
name: {
full: 'Alexia Midgar',
native: 'アレクシア・ミドガル',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
if (url === 'https://cdn.example.com/character-123.png') {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
if (url === 'https://cdn.example.com/va-456.jpg') {
return new Response(Buffer.from([0xff, 0xd8, 0xff, 0xd9]), {
status: 200,
headers: { 'content-type': 'image/jpeg' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
getCollapsibleSectionOpenState: (section) =>
section === 'description' || section === 'voicedBy',
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const alexia = termBank.find(([term]) => term === 'アレクシア');
assert.ok(alexia);
const glossary = alexia[5];
const entry = glossary[0] as {
type: string;
content: { tag: string; content: Array<Record<string, unknown>> };
};
const children = entry.content.content;
const getSection = (title: string) =>
children.find(
(c) =>
(c as { tag?: string }).tag === 'details' &&
Array.isArray((c as { content?: unknown[] }).content) &&
(c as { content: Array<{ content?: string }> }).content[0]?.content === title,
) as { open?: boolean } | undefined;
assert.equal(getSection('Description')?.open, true);
assert.equal(getSection('Character Information')?.open, false);
assert.equal(getSection('Voiced by')?.open, true);
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia reapplies collapsible open states when using cached snapshot data', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'The Eminence in Shadow',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
english: 'The Eminence in Shadow',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'SUPPORTING',
voiceActors: [
{
id: 456,
name: {
full: 'Rina Hidaka',
native: '日高里菜',
},
image: {
medium: 'https://cdn.example.com/va-456.jpg',
},
},
],
node: {
id: 123,
description:
'Alexia Midgar is the second princess of the Kingdom of Midgar.\n\n__Race:__ Human',
image: {
large: 'https://cdn.example.com/character-123.png',
medium: null,
},
name: {
full: 'Alexia Midgar',
native: 'アレクシア・ミドガル',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
if (url === 'https://cdn.example.com/character-123.png') {
return new Response(PNG_1X1, {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
if (url === 'https://cdn.example.com/va-456.jpg') {
return new Response(PNG_1X1, {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtimeOpen = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
getCollapsibleSectionOpenState: () => true,
now: () => 1_700_000_000_000,
});
await runtimeOpen.generateForCurrentMedia();
const runtimeClosed = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
getCollapsibleSectionOpenState: () => false,
now: () => 1_700_000_000_500,
});
const result = await runtimeClosed.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const alexia = termBank.find(([term]) => term === 'アレクシア');
assert.ok(alexia);
const children = (
alexia[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).content.content;
const sections = children.filter((item) => (item as { tag?: string }).tag === 'details') as Array<{
open?: boolean;
}>;
assert.ok(sections.length >= 2);
assert.ok(sections.every((section) => section.open === false));
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia adds kana aliases for romanized names when native name is kanji', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
@@ -369,6 +693,123 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
}
});
test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
};
if (body.query?.includes('Page(perPage: 10)')) {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
},
],
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: {
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
english: 'The Eminence in Shadow',
native: '陰の実力者になりたくて!',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 321,
description: 'Leader of Shadow Garden.',
image: null,
name: {
full: 'Cid Kagenou',
native: 'シド・カゲノー',
alternative: ['Shadow', 'Minoru Kagenou'],
},
},
},
],
},
},
},
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'The Eminence in Shadow',
episode: 5,
source: 'fallback',
}),
now: () => 1_700_000_000_000,
});
const result = await runtime.generateForCurrentMedia();
const termBank = JSON.parse(
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const shadowKana = termBank.find(([term]) => term === 'シャドウ');
assert.ok(shadowKana, 'expected katakana alias from AniList alternative name');
assert.equal(shadowKana[1], 'しゃどう');
} finally {
globalThis.fetch = originalFetch;
}
});
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
@@ -1158,6 +1599,306 @@ test('buildMergedDictionary combines stored snapshots into one stable dictionary
}
});
test('buildMergedDictionary rebuilds snapshots written with an older format version', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let characterQueryCount = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
variables?: Record<string, unknown>;
};
if (body.query?.includes('characters(page: $page')) {
characterQueryCount += 1;
assert.equal(body.variables?.id, 130298);
return new Response(
JSON.stringify({
data: {
Media: {
title: {
english: 'The Eminence in Shadow',
},
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 111,
description: 'Leader of Shadow Garden.',
image: null,
name: {
full: 'Cid Kagenou',
native: 'シド・カゲノー',
alternative: ['Shadow'],
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const snapshotsDir = path.join(userDataPath, 'character-dictionaries', 'snapshots');
fs.mkdirSync(snapshotsDir, { recursive: true });
fs.writeFileSync(
path.join(snapshotsDir, 'anilist-130298.json'),
JSON.stringify({
formatVersion: 12,
mediaId: 130298,
mediaTitle: 'The Eminence in Shadow',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [['stale', '', 'name main', '', 100, ['stale'], 0, '']],
images: [],
}),
'utf8',
);
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => null,
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => null,
now: () => 1_700_000_000_100,
});
const merged = await runtime.buildMergedDictionary([130298]);
const termBank = JSON.parse(
readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
assert.equal(characterQueryCount, 1);
assert.ok(termBank.find(([term]) => term === 'シャドウ'));
} finally {
globalThis.fetch = originalFetch;
}
});
test('buildMergedDictionary reapplies collapsible open states from current config', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
const current = { title: 'The Eminence in Shadow', episode: 5 };
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url === GRAPHQL_URL) {
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
variables?: Record<string, unknown>;
};
if (body.query?.includes('Page(perPage: 10)')) {
if (body.variables?.search === 'The Eminence in Shadow') {
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 130298,
episodes: 20,
title: {
english: 'The Eminence in Shadow',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 21,
episodes: 28,
title: {
english: 'Frieren: Beyond Journeys End',
},
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (body.query?.includes('characters(page: $page')) {
const mediaId = Number(body.variables?.id);
if (mediaId === 130298) {
return new Response(
JSON.stringify({
data: {
Media: {
title: { english: 'The Eminence in Shadow' },
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 111,
description: 'Leader of Shadow Garden.',
image: {
large: 'https://example.com/alpha.png',
medium: null,
},
name: {
full: 'Alpha',
native: 'アルファ',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(
JSON.stringify({
data: {
Media: {
title: { english: 'Frieren: Beyond Journeys End' },
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: {
id: 222,
description: 'Elven mage.',
image: {
large: 'https://example.com/frieren.png',
medium: null,
},
name: {
full: 'Frieren',
native: 'フリーレン',
},
},
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
}
if (url === 'https://example.com/alpha.png' || url === 'https://example.com/frieren.png') {
return new Response(PNG_1X1, {
status: 200,
headers: { 'content-type': 'image/png' },
});
}
throw new Error(`Unexpected fetch URL: ${url}`);
}) as typeof globalThis.fetch;
try {
const runtimeOpen = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/current.mkv',
getCurrentMediaTitle: () => current.title,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: current.title,
episode: current.episode,
source: 'fallback',
}),
getCollapsibleSectionOpenState: () => true,
now: () => 1_700_000_000_100,
});
await runtimeOpen.getOrCreateCurrentSnapshot();
current.title = 'Frieren: Beyond Journeys End';
current.episode = 1;
await runtimeOpen.getOrCreateCurrentSnapshot();
const runtimeClosed = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => '/tmp/current.mkv',
getCurrentMediaTitle: () => current.title,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: current.title,
episode: current.episode,
source: 'fallback',
}),
getCollapsibleSectionOpenState: () => false,
now: () => 1_700_000_000_200,
});
const merged = await runtimeClosed.buildMergedDictionary([21, 130298]);
const termBank = JSON.parse(
readStoredZipEntry(merged.zipPath, 'term_bank_1.json').toString('utf8'),
) as Array<
[
string,
string,
string,
string,
number,
Array<string | Record<string, unknown>>,
number,
string,
]
>;
const alpha = termBank.find(([term]) => term === 'アルファ');
assert.ok(alpha);
const children = (
alpha[5][0] as {
content: { content: Array<Record<string, unknown>> };
}
).content.content;
const sections = children.filter((item) => (item as { tag?: string }).tag === 'details') as Array<{
open?: boolean;
}>;
assert.ok(sections.length >= 1);
assert.ok(sections.every((section) => section.open === false));
} finally {
globalThis.fetch = originalFetch;
}
});
test('generateForCurrentMedia paces AniList requests and character image downloads', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;

View File

@@ -3,6 +3,7 @@ import * as os from 'os';
import * as path from 'path';
import { createHash } from 'node:crypto';
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../types';
import { hasVideoExtension } from '../shared/video-extensions';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -54,7 +55,7 @@ export type CharacterDictionarySnapshot = {
images: CharacterDictionarySnapshotImage[];
};
const CHARACTER_DICTIONARY_FORMAT_VERSION = 12;
const CHARACTER_DICTIONARY_FORMAT_VERSION = 14;
const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
type AniListSearchResponse = {
@@ -105,6 +106,7 @@ type AniListCharacterPageResponse = {
name?: {
full?: string | null;
native?: string | null;
alternative?: Array<string | null> | null;
} | null;
} | null;
} | null>;
@@ -124,6 +126,7 @@ type CharacterRecord = {
role: CharacterDictionaryRole;
fullName: string;
nativeName: string;
alternativeNames: string[];
description: string;
imageUrl: string | null;
voiceActors: VoiceActorRecord[];
@@ -178,6 +181,9 @@ export interface CharacterDictionaryRuntimeDeps {
sleep?: (ms: number) => Promise<void>;
logInfo?: (message: string) => void;
logWarn?: (message: string) => void;
getCollapsibleSectionOpenState?: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean;
}
type ResolvedAniListMedia = {
@@ -423,6 +429,7 @@ const ROMANIZED_KANA_MONOGRAPHS: ReadonlyArray<[string, string]> = [
['re', 'レ'],
['ro', 'ロ'],
['wa', 'ワ'],
['w', 'ウ'],
['wo', 'ヲ'],
['n', 'ン'],
];
@@ -490,37 +497,57 @@ function addRomanizedKanaAliases(values: Iterable<string>): string[] {
return [...aliases];
}
function expandRawNameVariants(rawName: string): string[] {
const trimmed = rawName.trim();
if (!trimmed) return [];
const variants = new Set<string>([trimmed]);
const outer = trimmed.replace(/[(][^()]+[)]/g, ' ').replace(/\s+/g, ' ').trim();
if (outer && outer !== trimmed) {
variants.add(outer);
}
for (const match of trimmed.matchAll(/[(]([^()]+)[)]/g)) {
const inner = match[1]?.trim() || '';
if (inner) {
variants.add(inner);
}
}
return [...variants];
}
function buildNameTerms(character: CharacterRecord): string[] {
const base = new Set<string>();
const rawNames = [character.nativeName, character.fullName];
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
for (const rawName of rawNames) {
const name = rawName.trim();
if (!name) continue;
base.add(name);
for (const name of expandRawNameVariants(rawName)) {
base.add(name);
const compact = name.replace(/[\s\u3000]+/g, '');
if (compact && compact !== name) {
base.add(compact);
}
const compact = name.replace(/[\s\u3000]+/g, '');
if (compact && compact !== name) {
base.add(compact);
}
const noMiddleDots = compact.replace(/[・・·•]/g, '');
if (noMiddleDots && noMiddleDots !== compact) {
base.add(noMiddleDots);
}
const noMiddleDots = compact.replace(/[・・·•]/g, '');
if (noMiddleDots && noMiddleDots !== compact) {
base.add(noMiddleDots);
}
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
base.add(split[0]!);
base.add(split[1]!);
}
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
if (split.length === 2) {
base.add(split[0]!);
base.add(split[1]!);
}
const splitByMiddleDot = name
.split(/[・・·•]/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
base.add(part);
const splitByMiddleDot = name
.split(/[・・·•]/)
.map((part) => part.trim())
.filter((part) => part.length > 0);
if (splitByMiddleDot.length >= 2) {
for (const part of splitByMiddleDot) {
base.add(part);
}
}
}
}
@@ -758,11 +785,12 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
function buildCollapsibleSection(
title: string,
open: boolean,
body: Array<string | Record<string, unknown>> | string | Record<string, unknown>,
): Record<string, unknown> {
return {
tag: 'details',
open: true,
open,
style: { marginTop: '0.4em' },
content: [
{
@@ -849,6 +877,9 @@ function createDefinitionGlossary(
mediaTitle: string,
imagePath: string | null,
vaImagePaths: Map<number, string>,
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionaryGlossaryEntry[] {
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
const secondaryName =
@@ -910,7 +941,13 @@ function createDefinitionGlossary(
});
if (descriptionText) {
content.push(buildCollapsibleSection('Description', descriptionText));
content.push(
buildCollapsibleSection(
'Description',
getCollapsibleSectionOpenState('description'),
descriptionText,
),
);
}
if (fields.length > 0) {
@@ -919,11 +956,15 @@ function createDefinitionGlossary(
content: `${f.key}: ${f.value}`,
}));
content.push(
buildCollapsibleSection('Character Information', {
tag: 'ul',
style: { marginTop: '0.15em' },
content: fieldItems,
}),
buildCollapsibleSection(
'Character Information',
getCollapsibleSectionOpenState('characterInformation'),
{
tag: 'ul',
style: { marginTop: '0.15em' },
content: fieldItems,
},
),
);
}
@@ -931,6 +972,7 @@ function createDefinitionGlossary(
content.push(
buildCollapsibleSection(
'Voiced by',
getCollapsibleSectionOpenState('voicedBy'),
buildVoicedByContent(character.voiceActors, vaImagePaths),
),
);
@@ -1210,6 +1252,7 @@ async function fetchCharactersForMedia(
name {
full
native
alternative
}
}
}
@@ -1243,7 +1286,13 @@ async function fetchCharactersForMedia(
if (!node || typeof node.id !== 'number') continue;
const fullName = node.name?.full?.trim() || '';
const nativeName = node.name?.native?.trim() || '';
if (!fullName && !nativeName) continue;
const alternativeNames = [...new Set(
(node.name?.alternative ?? [])
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0),
)];
if (!fullName && !nativeName && alternativeNames.length === 0) continue;
const voiceActors: VoiceActorRecord[] = [];
for (const va of edge?.voiceActors ?? []) {
if (!va || typeof va.id !== 'number') continue;
@@ -1262,6 +1311,7 @@ async function fetchCharactersForMedia(
role: mapRole(edge?.role),
fullName,
nativeName,
alternativeNames,
description: node.description || '',
imageUrl: node.image?.large || node.image?.medium || null,
voiceActors,
@@ -1340,6 +1390,9 @@ function buildSnapshotFromCharacters(
imagesByCharacterId: Map<number, CharacterDictionarySnapshotImage>,
imagesByVaId: Map<number, CharacterDictionarySnapshotImage>,
updatedAt: number,
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionarySnapshot {
const termEntries: CharacterDictionaryTermEntry[] = [];
const seen = new Set<string>();
@@ -1351,7 +1404,13 @@ function buildSnapshotFromCharacters(
const vaImg = imagesByVaId.get(va.id);
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
}
const glossary = createDefinitionGlossary(character, mediaTitle, imagePath, vaImagePaths);
const glossary = createDefinitionGlossary(
character,
mediaTitle,
imagePath,
vaImagePaths,
getCollapsibleSectionOpenState,
);
const candidateTerms = buildNameTerms(character);
for (const term of candidateTerms) {
const reading = buildReading(term);
@@ -1377,6 +1436,67 @@ function buildSnapshotFromCharacters(
};
}
function getCollapsibleSectionKeyFromTitle(
title: string,
): AnilistCharacterDictionaryCollapsibleSectionKey | null {
if (title === 'Description') return 'description';
if (title === 'Character Information') return 'characterInformation';
if (title === 'Voiced by') return 'voicedBy';
return null;
}
function applyCollapsibleOpenStatesToStructuredValue(
value: unknown,
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): unknown {
if (Array.isArray(value)) {
return value.map((item) =>
applyCollapsibleOpenStatesToStructuredValue(item, getCollapsibleSectionOpenState),
);
}
if (!value || typeof value !== 'object') {
return value;
}
const record = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
for (const [key, child] of Object.entries(record)) {
next[key] = applyCollapsibleOpenStatesToStructuredValue(child, getCollapsibleSectionOpenState);
}
if (record.tag === 'details') {
const content = Array.isArray(record.content) ? record.content : [];
const summary = content[0];
if (summary && typeof summary === 'object' && !Array.isArray(summary)) {
const summaryContent = (summary as Record<string, unknown>).content;
if (typeof summaryContent === 'string') {
const section = getCollapsibleSectionKeyFromTitle(summaryContent);
if (section) {
next.open = getCollapsibleSectionOpenState(section);
}
}
}
}
return next;
}
function applyCollapsibleOpenStatesToTermEntries(
termEntries: CharacterDictionaryTermEntry[],
getCollapsibleSectionOpenState: (
section: AnilistCharacterDictionaryCollapsibleSectionKey,
) => boolean,
): CharacterDictionaryTermEntry[] {
return termEntries.map((entry) => {
const glossary = entry[5].map((item) =>
applyCollapsibleOpenStatesToStructuredValue(item, getCollapsibleSectionOpenState),
) as CharacterDictionaryGlossaryEntry[];
return [...entry.slice(0, 5), glossary, ...entry.slice(6)] as CharacterDictionaryTermEntry;
});
}
function buildDictionaryZip(
outputPath: string,
dictionaryTitle: string,
@@ -1444,6 +1564,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} {
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
const sleepMs = deps.sleep ?? sleep;
const getCollapsibleSectionOpenState = deps.getCollapsibleSectionOpenState ?? (() => false);
const resolveCurrentMedia = async (
targetPath?: string,
@@ -1557,6 +1678,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
imagesByCharacterId,
imagesByVaId,
deps.now(),
getCollapsibleSectionOpenState,
);
writeSnapshot(snapshotPath, snapshot);
deps.logInfo?.(
@@ -1589,7 +1711,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
const normalizedMediaIds = mediaIds
.filter((mediaId) => Number.isFinite(mediaId) && mediaId > 0)
.map((mediaId) => Math.floor(mediaId));
const snapshots = normalizedMediaIds.map((mediaId) => {
const snapshotResults = await Promise.all(
normalizedMediaIds.map((mediaId) => getOrCreateSnapshot(mediaId)),
);
const snapshots = snapshotResults.map(({ mediaId }) => {
const snapshot = readSnapshot(getSnapshotPath(outputDir, mediaId));
if (!snapshot) {
throw new Error(`Missing character dictionary snapshot for AniList ${mediaId}.`);
@@ -1606,7 +1731,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
CHARACTER_DICTIONARY_MERGED_TITLE,
description,
revision,
snapshots.flatMap((snapshot) => snapshot.termEntries),
applyCollapsibleOpenStatesToTermEntries(
snapshots.flatMap((snapshot) => snapshot.termEntries),
getCollapsibleSectionOpenState,
),
snapshots.flatMap((snapshot) => snapshot.images),
);
deps.logInfo?.(
@@ -1651,7 +1779,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
dictionaryTitle,
description,
revision,
storedSnapshot.termEntries,
applyCollapsibleOpenStatesToTermEntries(
storedSnapshot.termEntries,
getCollapsibleSectionOpenState,
),
storedSnapshot.images,
);
deps.logInfo?.(

View File

@@ -18,6 +18,7 @@ export interface CliCommandRuntimeServiceContext {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -103,6 +104,7 @@ function createCliCommandDepsFromContext(
runCommand: context.runJellyfinCommand,
},
ui: {
openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings,
cycleSecondarySubMode: context.cycleSecondarySubMode,
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,

View File

@@ -159,6 +159,7 @@ export interface CliCommandRuntimeServiceDepsParams {
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
};
ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
@@ -307,6 +308,7 @@ export function createCliCommandRuntimeServiceDeps(
runCommand: params.jellyfin.runCommand,
},
ui: {
openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,

View File

@@ -76,7 +76,7 @@ test('register subminer protocol client main deps builder maps callbacks', () =>
execPath: '/tmp/electron',
resolvePath: (value) => `/abs/${value}`,
setAsDefaultProtocolClient: () => true,
logWarn: (message) => calls.push(`warn:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
})();
assert.equal(deps.isDefaultApp(), true);

View File

@@ -60,6 +60,6 @@ export function createBuildRegisterSubminerProtocolClientMainDepsHandler(
resolvePath: (value: string) => deps.resolvePath(value),
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) =>
deps.setAsDefaultProtocolClient(scheme, path, args),
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
logDebug: (message: string, details?: unknown) => deps.logDebug(message, details),
});
}

View File

@@ -56,9 +56,24 @@ test('createRegisterSubminerProtocolClientHandler registers default app entry',
calls.push(`register:${String(args?.[0])}`);
return true;
},
logWarn: (message) => calls.push(`warn:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
});
register();
assert.deepEqual(calls, ['register:/resolved/./entry.js']);
});
test('createRegisterSubminerProtocolClientHandler keeps unsupported registration at debug level', () => {
const calls: string[] = [];
const register = createRegisterSubminerProtocolClientHandler({
isDefaultApp: () => false,
getArgv: () => ['SubMiner.AppImage'],
execPath: '/tmp/SubMiner.AppImage',
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => false,
logDebug: (message) => calls.push(`debug:${message}`),
});
register();
assert.deepEqual(calls, ['debug:Failed to register default protocol handler for subminer:// URLs']);
});

View File

@@ -67,7 +67,7 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
execPath: string;
resolvePath: (value: string) => string;
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
logWarn: (message: string, details?: unknown) => void;
logDebug: (message: string, details?: unknown) => void;
}) {
return (): void => {
try {
@@ -78,10 +78,10 @@ export function createRegisterSubminerProtocolClientHandler(deps: {
])
: deps.setAsDefaultProtocolClient('subminer');
if (!success) {
deps.logWarn('Failed to register default protocol handler for subminer:// URLs');
deps.logDebug('Failed to register default protocol handler for subminer:// URLs');
}
} catch (error) {
deps.logWarn('Failed to register subminer:// protocol handler', error);
deps.logDebug('Failed to register subminer:// protocol handler', error);
}
};
}

View File

@@ -5,6 +5,7 @@ import { createBuildAppReadyRuntimeMainDepsHandler } from './app-ready-main-deps
test('app-ready main deps builder returns mapped app-ready runtime deps', async () => {
const calls: string[] = [];
const onReady = createBuildAppReadyRuntimeMainDepsHandler({
ensureDefaultConfigBootstrap: () => calls.push('bootstrap-config'),
loadSubtitlePosition: () => calls.push('load-subtitle-position'),
resolveKeybindings: () => calls.push('resolve-keybindings'),
createMpvClient: () => calls.push('create-mpv-client'),
@@ -16,8 +17,12 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
setSecondarySubMode: () => calls.push('set-secondary-sub-mode'),
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => calls.push('start-ws'),
startAnnotationWebsocket: () => calls.push('start-annotation-ws'),
startTexthooker: () => calls.push('start-texthooker'),
log: () => calls.push('log'),
setLogLevel: () => calls.push('set-log-level'),
createMecabTokenizerAndCheck: async () => {
@@ -31,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup');
},
prewarmSubtitleDictionaries: async () => {
calls.push('prewarm-dicts');
},
@@ -49,6 +57,8 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
assert.equal(onReady.defaultSecondarySubMode, 'hover');
assert.equal(onReady.defaultWebsocketPort, 5174);
assert.equal(onReady.defaultAnnotationWebsocketPort, 6678);
assert.equal(onReady.defaultTexthookerPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.now?.(), 123);
@@ -57,8 +67,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
onReady.startTexthooker(5174);
onReady.setVisibleOverlayVisible(true);
assert.deepEqual(calls, [
@@ -67,8 +79,10 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'create-mpv-client',
'create-mecab',
'load-yomitan',
'handle-first-run-setup',
'prewarm-dicts',
'start-warmups',
'start-texthooker',
'set-visible-overlay',
]);
});

View File

@@ -2,6 +2,7 @@ import type { AppReadyRuntimeDepsFactoryInput } from '../app-lifecycle';
export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeDepsFactoryInput) {
return (): AppReadyRuntimeDepsFactoryInput => ({
ensureDefaultConfigBootstrap: deps.ensureDefaultConfigBootstrap,
loadSubtitlePosition: deps.loadSubtitlePosition,
resolveKeybindings: deps.resolveKeybindings,
createMpvClient: deps.createMpvClient,
@@ -13,8 +14,12 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
setSecondarySubMode: deps.setSecondarySubMode,
defaultSecondarySubMode: deps.defaultSecondarySubMode,
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
defaultTexthookerPort: deps.defaultTexthookerPort,
hasMpvWebsocketPlugin: deps.hasMpvWebsocketPlugin,
startSubtitleWebsocket: deps.startSubtitleWebsocket,
startAnnotationWebsocket: deps.startAnnotationWebsocket,
startTexthooker: deps.startTexthooker,
log: deps.log,
setLogLevel: deps.setLogLevel,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
@@ -22,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
handleFirstRunSetup: deps.handleFirstRunSetup,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
texthookerOnlyMode: deps.texthookerOnlyMode,

View File

@@ -18,6 +18,7 @@ test('build cli command context deps maps handlers and values', () => {
isOverlayInitialized: () => true,
initializeOverlay: () => calls.push('init'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetup: () => calls.push('setup'),
setVisibleOverlay: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy'),
startPendingMultiCopy: (ms) => calls.push(`multi:${ms}`),

View File

@@ -16,6 +16,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -61,6 +62,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,

View File

@@ -20,6 +20,7 @@ test('cli command context factory composes main deps and context handlers', () =
showMpvOsd: (text) => calls.push(`osd:${text}`),
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetupWindow: () => calls.push('setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
startPendingMultiCopy: (timeoutMs) => calls.push(`multi:${timeoutMs}`),

View File

@@ -23,6 +23,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
openFirstRunSetupWindow: () => calls.push('open-setup'),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
@@ -107,10 +108,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.openFirstRunSetup();
deps.setVisibleOverlay(true);
deps.printHelp();
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'set-visible:true', 'help']);
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });

View File

@@ -19,6 +19,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -71,6 +72,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
isOverlayInitialized: () => deps.appState.overlayRuntimeInitialized,
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),

View File

@@ -24,6 +24,7 @@ function createDeps() {
isOverlayInitialized: () => true,
initializeOverlay: () => {},
toggleVisibleOverlay: () => {},
openFirstRunSetup: () => {},
setVisibleOverlay: () => {},
copyCurrentSubtitle: () => {},
startPendingMultiCopy: () => {},

View File

@@ -21,6 +21,7 @@ export type CliCommandContextFactoryDeps = {
isOverlayInitialized: () => boolean;
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -73,6 +74,7 @@ export function createCliCommandContext(
isOverlayInitialized: deps.isOverlayInitialized,
initializeOverlay: deps.initializeOverlay,
toggleVisibleOverlay: deps.toggleVisibleOverlay,
openFirstRunSetup: deps.openFirstRunSetup,
setVisibleOverlay: deps.setVisibleOverlay,
copyCurrentSubtitle: deps.copyCurrentSubtitle,
startPendingMultiCopy: deps.startPendingMultiCopy,

View File

@@ -29,7 +29,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
execPath: process.execPath,
resolvePath: (value) => value,
setAsDefaultProtocolClient: () => true,
logWarn: () => {},
logDebug: () => {},
},
});

View File

@@ -26,6 +26,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
@@ -37,12 +38,17 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},

View File

@@ -0,0 +1,103 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
detectInstalledFirstRunPlugin,
installFirstRunPluginToDefaultLocation,
resolvePackagedFirstRunPluginAssets,
} from './first-run-setup-plugin';
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-plugin-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
test('resolvePackagedFirstRunPluginAssets finds packaged plugin assets', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const resolved = resolvePackagedFirstRunPluginAssets({
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.deepEqual(resolved, {
pluginDirSource: path.join(pluginRoot, 'subminer'),
pluginConfigSource: path.join(pluginRoot, 'subminer.conf'),
});
});
});
test('installFirstRunPluginToDefaultLocation installs plugin and backs up existing files', () => {
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'linux',
homeDir,
xdgConfigHome,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
'-- packaged plugin',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
const scriptsDirEntries = fs.readdirSync(installPaths.scriptsDir);
const scriptOptsEntries = fs.readdirSync(installPaths.scriptOptsDir);
assert.equal(scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')), true);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
);
});
});
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir: '/tmp/home',
xdgConfigHome: '/tmp/xdg',
dirname: '/tmp/dist/main/runtime',
appPath: '/tmp/app',
resourcesPath: '/tmp/resources',
});
assert.equal(result.ok, false);
assert.equal(result.pluginInstallStatus, 'failed');
assert.match(result.message, /not supported/i);
});

View File

@@ -0,0 +1,100 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
}
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): { pluginDirSource: string; pluginConfigSource: string } | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const roots = [
joinPath(deps.resourcesPath, 'plugin'),
joinPath(deps.resourcesPath, 'app.asar', 'plugin'),
joinPath(deps.appPath, 'plugin'),
joinPath(deps.dirname, '..', 'plugin'),
joinPath(deps.dirname, '..', '..', 'plugin'),
];
for (const root of roots) {
const pluginDirSource = joinPath(root, 'subminer');
const pluginConfigSource = joinPath(root, 'subminer.conf');
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
return { pluginDirSource, pluginConfigSource };
}
}
return null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: { existsSync?: (candidate: string) => boolean },
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
dirname: string;
appPath: string;
resourcesPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
return {
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
};
}

View File

@@ -0,0 +1,174 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
createFirstRunSetupService,
shouldAutoOpenFirstRunSetup,
} from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
const result = fn(dir);
if (result instanceof Promise) {
return result.finally(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
}
fs.rmSync(dir, { recursive: true, force: true });
}
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
stop: false,
toggle: false,
toggleVisibleOverlay: false,
settings: false,
setup: false,
show: false,
hide: false,
showVisibleOverlay: false,
hideVisibleOverlay: false,
copySubtitle: false,
copySubtitleMultiple: false,
mineSentence: false,
mineSentenceMultiple: false,
updateLastCardFromClipboard: false,
refreshKnownWords: false,
toggleSecondarySub: false,
triggerFieldGrouping: false,
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinLibraries: false,
jellyfinItems: false,
jellyfinSubtitles: false,
jellyfinSubtitleUrlsOnly: false,
jellyfinPlay: false,
jellyfinRemoteAnnounce: false,
jellyfinPreviewAuth: false,
texthooker: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
debug: false,
...overrides,
};
}
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
});
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 2,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: 'installed',
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.state.status, 'completed');
assert.equal(snapshot.state.completionSource, 'legacy_auto_detected');
assert.equal(snapshot.dictionaryCount, 2);
assert.equal(snapshot.canFinish, true);
});
});
test('setup service requires explicit finish for incomplete installs and supports plugin skip/install', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let dictionaryCount = 0;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => dictionaryCount,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: 'installed',
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.state.status, 'incomplete');
assert.equal(initial.canFinish, false);
const skipped = await service.skipPluginInstall();
assert.equal(skipped.state.pluginInstallStatus, 'skipped');
const installed = await service.installMpvPlugin();
assert.equal(installed.state.pluginInstallStatus, 'installed');
assert.equal(installed.pluginInstallPathSummary, '/tmp/mpv');
dictionaryCount = 1;
const refreshed = await service.refreshStatus();
assert.equal(refreshed.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.state.completionSource, 'user');
});
});
test('setup service marks cancelled when popup closes before completion', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
onStateChanged: () => undefined,
});
await service.ensureSetupStateInitialized();
await service.markSetupInProgress();
const cancelled = await service.markSetupCancelled();
assert.equal(cancelled.state.status, 'cancelled');
});
});

View File

@@ -0,0 +1,222 @@
import fs from 'node:fs';
import {
createDefaultSetupState,
getDefaultConfigFilePaths,
getSetupStatePath,
isSetupCompleted,
readSetupState,
writeSetupState,
type SetupPluginInstallStatus,
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
message: string | null;
state: SetupState;
}
export interface PluginInstallResult {
ok: boolean;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
message: string;
}
export interface FirstRunSetupService {
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
getSetupStatus: () => Promise<SetupStatusSnapshot>;
refreshStatus: (message?: string | null) => Promise<SetupStatusSnapshot>;
markSetupInProgress: () => Promise<SetupStatusSnapshot>;
markSetupCancelled: () => Promise<SetupStatusSnapshot>;
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.settings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
args.hideVisibleOverlay ||
args.copySubtitle ||
args.copySubtitleMultiple ||
args.mineSentence ||
args.mineSentenceMultiple ||
args.updateLastCardFromClipboard ||
args.refreshKnownWords ||
args.toggleSecondarySub ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.dictionary ||
args.jellyfin ||
args.jellyfinLogin ||
args.jellyfinLogout ||
args.jellyfinLibraries ||
args.jellyfinItems ||
args.jellyfinSubtitles ||
args.jellyfinPlay ||
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.help
);
}
export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
if (args.setup) return true;
if (!args.start && !args.background) return false;
return !hasAnyStartupCommandBeyondSetup(args);
}
function getPluginStatus(state: SetupState, pluginInstalled: boolean): SetupStatusSnapshot['pluginStatus'] {
if (pluginInstalled) return 'installed';
if (state.pluginInstallStatus === 'skipped') return 'skipped';
if (state.pluginInstallStatus === 'failed') return 'failed';
return 'optional';
}
export function createFirstRunSetupService(deps: {
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
const writeState = (state: SetupState): SetupState => {
writeSetupState(setupStatePath, state);
completed = state.status === 'completed';
deps.onStateChanged?.(state);
return state;
};
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const pluginInstalled = await deps.detectPluginInstalled();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
configReady,
dictionaryCount,
canFinish: dictionaryCount >= 1,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
message,
state,
} satisfies SetupStatusSnapshot;
};
const refreshWithState = async (state: SetupState, message: string | null = null) => {
const snapshot = await buildSnapshot(state, message);
if (snapshot.state.lastSeenYomitanDictionaryCount !== snapshot.dictionaryCount) {
snapshot.state = writeState({
...snapshot.state,
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
});
}
return snapshot;
};
return {
ensureSetupStateInitialized: async () => {
const state = readState();
if (isSetupCompleted(state)) {
completed = true;
return refreshWithState(state);
}
const dictionaryCount = await deps.getYomitanDictionaryCount();
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
if (configReady && dictionaryCount >= 1) {
const completedState = writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'legacy_auto_detected',
lastSeenYomitanDictionaryCount: dictionaryCount,
});
return buildSnapshot(completedState);
}
return refreshWithState(
writeState({
...state,
status: state.status === 'cancelled' ? 'cancelled' : 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: dictionaryCount,
}),
);
},
getSetupStatus: async () => refreshWithState(readState()),
refreshStatus: async (message = null) => refreshWithState(readState(), message),
markSetupInProgress: async () => {
const state = readState();
if (state.status === 'completed') {
completed = true;
return refreshWithState(state);
}
return refreshWithState(writeState({ ...state, status: 'in_progress' }));
},
markSetupCancelled: async () => {
const state = readState();
if (state.status === 'completed') {
completed = true;
return refreshWithState(state);
}
return refreshWithState(writeState({ ...state, status: 'cancelled' }));
},
markSetupCompleted: async () => {
const state = readState();
const snapshot = await buildSnapshot(state);
if (!snapshot.canFinish) {
return snapshot;
}
return refreshWithState(
writeState({
...state,
status: 'completed',
completedAt: new Date().toISOString(),
completionSource: 'user',
lastSeenYomitanDictionaryCount: snapshot.dictionaryCount,
}),
);
},
skipPluginInstall: async () =>
refreshWithState(writeState({ ...readState(), pluginInstallStatus: 'skipped' })),
installMpvPlugin: async () => {
const result = await deps.installPlugin();
return refreshWithState(
writeState({
...readState(),
pluginInstallStatus: result.pluginInstallStatus,
pluginInstallPathSummary: result.pluginInstallPathSummary,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}

View File

@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 0,
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
message: 'Waiting for dictionaries',
});
assert.match(html, /SubMiner setup/);
assert.match(html, /Install mpv plugin/);
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
});
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
message: null,
});
assert.match(html, /Reinstall mpv plugin/);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({
focus: () => calls.push('focus'),
}),
});
assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['focus']);
});
test('first-run setup navigation handler prevents default and dispatches action', async () => {
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (action) => {
calls.push(action);
},
logError: (message) => calls.push(message),
});
const prevented = handleNavigation({
url: 'subminer://first-run-setup?action=install-plugin',
preventDefault: () => calls.push('preventDefault'),
});
assert.equal(prevented, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});

View File

@@ -0,0 +1,329 @@
type FocusableWindowLike = {
focus: () => void;
};
type FirstRunSetupWebContentsLike = {
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
};
type FirstRunSetupWindowLike = FocusableWindowLike & {
webContents: FirstRunSetupWebContentsLike;
loadURL: (url: string) => unknown;
on: (event: 'closed', handler: () => void) => void;
isDestroyed: () => boolean;
close: () => void;
};
export type FirstRunSetupAction =
| 'install-plugin'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
message: string | null;
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'danger'): string {
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const pluginActionLabel =
model.pluginStatus === 'installed' ? 'Reinstall mpv plugin' : 'Install mpv plugin';
const pluginLabel =
model.pluginStatus === 'installed'
? 'Installed'
: model.pluginStatus === 'skipped'
? 'Skipped'
: model.pluginStatus === 'failed'
? 'Failed'
: 'Optional';
const pluginTone =
model.pluginStatus === 'installed'
? 'ready'
: model.pluginStatus === 'failed'
? 'danger'
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>SubMiner First-Run Setup</title>
<style>
:root {
color-scheme: dark;
--base: #24273a;
--mantle: #1e2030;
--surface: #363a4f;
--surface-strong: #494d64;
--text: #cad3f5;
--muted: #b8c0e0;
--blue: #8aadf4;
--green: #a6da95;
--yellow: #eed49f;
--red: #ed8796;
}
body {
margin: 0;
background: linear-gradient(180deg, var(--mantle), var(--base));
color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
padding: 18px;
}
h1 {
margin: 0 0 6px;
font-size: 18px;
}
p {
margin: 0 0 14px;
color: var(--muted);
}
.card {
background: rgba(54, 58, 79, 0.92);
border: 1px solid rgba(202, 211, 245, 0.08);
border-radius: 12px;
padding: 12px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 9px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
}
.badge.ready { background: rgba(166, 218, 149, 0.16); color: var(--green); }
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 14px;
}
button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
font-weight: 700;
color: var(--text);
background: var(--surface);
}
button.primary {
background: var(--blue);
color: #1e2030;
}
button.ghost {
background: transparent;
border: 1px solid rgba(202, 211, 245, 0.12);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.message {
min-height: 18px;
margin-top: 12px;
color: var(--muted);
}
.footer {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
}
</style>
</head>
<body>
<main>
<h1>SubMiner setup</h1>
<div class="card">
<div>
<strong>Config file</strong>
<div class="meta">Default config directory seeded automatically.</div>
</div>
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
</div>
<div class="card">
<div>
<strong>mpv plugin</strong>
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
</div>
${renderStatusBadge(pluginLabel, pluginTone)}
</div>
<div class="card">
<div>
<strong>Yomitan dictionaries</strong>
<div class="meta">${model.dictionaryCount} installed</div>
</div>
${renderStatusBadge(
model.dictionaryCount >= 1 ? 'Ready' : 'Missing',
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div>
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=skip-plugin'">Skip plugin</button>
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">Finish setup</button>
</div>
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
<div class="footer">Finish stays locked until Yomitan reports at least one installed dictionary.</div>
</main>
</body>
</html>`;
}
export function parseFirstRunSetupSubmissionUrl(
rawUrl: string,
): { action: FirstRunSetupAction } | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'install-plugin' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
action !== 'finish'
) {
return null;
}
return { action };
}
export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) return false;
window.focus();
return true;
};
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
params.preventDefault();
void deps.handleAction(submission.action).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
};
}
export function createOpenFirstRunSetupWindowHandler<TWindow extends FirstRunSetupWindowLike>(deps: {
maybeFocusExistingSetupWindow: () => boolean;
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
logError: (message: string, error: unknown) => void;
}) {
return (): void => {
if (deps.maybeFocusExistingSetupWindow()) {
return;
}
const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow);
const render = async (): Promise<void> => {
const model = await deps.getSetupSnapshot();
const html = deps.buildSetupHtml(model);
await setupWindow.loadURL(
`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`,
);
};
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (action) => {
const result = await deps.handleAction(action);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
}
return;
}
if (!setupWindow.isDestroyed()) {
await render();
}
},
logError: deps.logError,
});
setupWindow.webContents.on('will-navigate', (event, url) => {
handleNavigation({
url,
preventDefault: () => {
if (event && typeof event === 'object' && 'preventDefault' in event) {
(event as { preventDefault?: () => void }).preventDefault?.();
}
},
});
});
setupWindow.on('closed', () => {
if (!deps.isSetupCompleted()) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
});
void deps
.markSetupInProgress()
.then(() => render())
.catch((error) => deps.logError('Failed opening first-run setup window', error));
};
}

View File

@@ -42,6 +42,7 @@ test('build tray template handler wires actions and init guards', () => {
const buildTemplate = createBuildTrayMenuTemplateHandler({
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openFirstRunSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
@@ -55,6 +56,8 @@ test('build tray template handler wires actions and init guards', () => {
},
isOverlayRuntimeInitialized: () => initialized,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -67,6 +70,7 @@ test('build tray template handler wires actions and init guards', () => {
assert.deepEqual(calls, [
'init',
'visible:true',
'setup',
'yomitan',
'runtime-options',
'jellyfin',

View File

@@ -29,6 +29,8 @@ export function createResolveTrayIconPathHandler(deps: {
export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -38,6 +40,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -52,6 +56,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}
deps.setVisibleOverlayVisible(true);
},
openFirstRunSetup: () => {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},

View File

@@ -25,6 +25,8 @@ test('tray main deps builders return mapped handlers', () => {
initializeOverlayRuntime: () => calls.push('init'),
isOverlayRuntimeInitialized: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -34,6 +36,8 @@ test('tray main deps builders return mapped handlers', () => {
const template = menuDeps.buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('open-overlay'),
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),

View File

@@ -28,6 +28,8 @@ export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -37,6 +39,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -48,6 +52,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
initializeOverlayRuntime: deps.initializeOverlayRuntime,
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,

View File

@@ -27,6 +27,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
setVisibleOverlayVisible: (visible) => {
visibleOverlay = visible;
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},

View File

@@ -30,6 +30,8 @@ test('tray menu template contains expected entries and handlers', () => {
const calls: string[] = [];
const template = buildTrayMenuTemplateRuntime({
openOverlay: () => calls.push('overlay'),
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
@@ -37,9 +39,26 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 7);
assert.equal(template.length, 8);
template[0]!.click?.();
template[5]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[6]!.click?.();
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[7]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openOverlay: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
openAnilistSetup: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
});

View File

@@ -31,6 +31,8 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -48,6 +50,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Overlay',
click: handlers.openOverlay,
},
...(handlers.showFirstRunSetup
? [
{
label: 'Complete Setup',
click: handlers.openFirstRunSetup,
},
]
: []),
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,

View File

@@ -147,6 +147,7 @@ export interface AppState {
yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null;
jellyfinSetupWindow: BrowserWindow | null;
firstRunSetupWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null;
@@ -193,6 +194,7 @@ export interface AppState {
frequencyRankLookup: FrequencyDictionaryLookup;
anilistSetupPageOpened: boolean;
anilistRetryQueueState: AnilistRetryQueueState;
firstRunSetupCompleted: boolean;
}
export interface AppStateInitialValues {
@@ -221,6 +223,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanParserWindow: null,
anilistSetupWindow: null,
jellyfinSetupWindow: null,
firstRunSetupWindow: null,
yomitanParserReadyPromise: null,
yomitanParserInitPromise: null,
mpvClient: null,
@@ -269,6 +272,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
anilistRetryQueueState: createInitialAnilistRetryQueueState(),
firstRunSetupCompleted: false,
};
}

View File

@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
createDefaultSetupState,
ensureDefaultConfigBootstrap,
getDefaultConfigDir,
getDefaultConfigFilePaths,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
writeSetupState,
} from './setup-state';
function withTempDir(fn: (dir: string) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-setup-state-test-'));
try {
fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
const dir = getDefaultConfigDir({
xdgConfigHome: '/tmp/xdg',
homeDir: '/tmp/home',
existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc',
});
assert.equal(dir, '/tmp/xdg/SubMiner');
});
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
withTempDir((root) => {
const configDir = path.join(root, 'SubMiner');
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => '{\n "logging": {}\n}\n',
});
assert.equal(fs.existsSync(configDir), true);
assert.equal(fs.readFileSync(path.join(configDir, 'config.jsonc'), 'utf8'), '{\n "logging": {}\n}\n');
fs.writeFileSync(path.join(configDir, 'config.json'), '{"keep":true}\n');
fs.rmSync(path.join(configDir, 'config.jsonc'));
ensureDefaultConfigBootstrap({
configDir,
configFilePaths: getDefaultConfigFilePaths(configDir),
generateTemplate: () => 'should-not-write',
});
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
assert.equal(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8'), '{"keep":true}\n');
});
});
test('readSetupState ignores invalid files and round-trips valid state', () => {
withTempDir((root) => {
const statePath = getSetupStatePath(root);
fs.writeFileSync(statePath, '{invalid');
assert.equal(readSetupState(statePath), null);
const state = createDefaultSetupState();
state.status = 'completed';
state.completionSource = 'user';
state.lastSeenYomitanDictionaryCount = 2;
writeSetupState(statePath, state);
assert.deepEqual(readSetupState(statePath), state);
});
});
test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => {
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), {
supported: true,
mpvConfigDir: '/tmp/xdg/mpv',
scriptsDir: '/tmp/xdg/mpv/scripts',
scriptOptsDir: '/tmp/xdg/mpv/script-opts',
pluginDir: '/tmp/xdg/mpv/scripts/subminer',
pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf',
});
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), {
supported: true,
mpvConfigDir: '/Users/tester/Library/Application Support/mpv',
scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts',
scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts',
pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer',
pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf',
});
});

192
src/shared/setup-state.ts Normal file
View File

@@ -0,0 +1,192 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { resolveConfigDir } from '../config/path-resolution';
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
export interface SetupState {
version: 1;
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
lastSeenYomitanDictionaryCount: number;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
}
export interface ConfigFilePaths {
jsoncPath: string;
jsonPath: string;
}
export interface MpvInstallPaths {
supported: boolean;
mpvConfigDir: string;
scriptsDir: string;
scriptOptsDir: string;
pluginDir: string;
pluginConfigPath: string;
}
function asObject(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function createDefaultSetupState(): SetupState {
return {
version: 1,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
};
}
export function normalizeSetupState(value: unknown): SetupState | null {
const record = asObject(value);
if (!record) return null;
const status = record.status;
const pluginInstallStatus = record.pluginInstallStatus;
const completionSource = record.completionSource;
if (
record.version !== 1 ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
status !== 'cancelled') ||
(pluginInstallStatus !== 'unknown' &&
pluginInstallStatus !== 'installed' &&
pluginInstallStatus !== 'skipped' &&
pluginInstallStatus !== 'failed') ||
(completionSource !== null &&
completionSource !== 'user' &&
completionSource !== 'legacy_auto_detected')
) {
return null;
}
return {
version: 1,
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
lastSeenYomitanDictionaryCount:
typeof record.lastSeenYomitanDictionaryCount === 'number' &&
Number.isFinite(record.lastSeenYomitanDictionaryCount) &&
record.lastSeenYomitanDictionaryCount >= 0
? Math.floor(record.lastSeenYomitanDictionaryCount)
: 0,
pluginInstallStatus,
pluginInstallPathSummary:
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
};
}
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
return state?.status === 'completed';
}
export function getDefaultConfigDir(options?: {
xdgConfigHome?: string;
homeDir?: string;
existsSync?: (candidate: string) => boolean;
}): string {
return resolveConfigDir({
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync,
});
}
export function getDefaultConfigFilePaths(configDir: string): ConfigFilePaths {
return {
jsoncPath: path.join(configDir, 'config.jsonc'),
jsonPath: path.join(configDir, 'config.json'),
};
}
export function getSetupStatePath(configDir: string): string {
return path.join(configDir, 'setup-state.json');
}
export function readSetupState(
statePath: string,
deps?: {
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
},
): SetupState | null {
const existsSync = deps?.existsSync ?? fs.existsSync;
const readFileSync = deps?.readFileSync ?? fs.readFileSync;
if (!existsSync(statePath)) return null;
try {
return normalizeSetupState(JSON.parse(readFileSync(statePath, 'utf8')));
} catch {
return null;
}
}
export function writeSetupState(
statePath: string,
state: SetupState,
deps?: {
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
},
): void {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync;
mkdirSync(path.dirname(statePath), { recursive: true });
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
export function ensureDefaultConfigBootstrap(options: {
configDir: string;
configFilePaths: ConfigFilePaths;
generateTemplate: () => string;
existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
}): void {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
mkdirSync(options.configDir, { recursive: true });
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
return;
}
writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8');
}
export function resolveDefaultMpvInstallPaths(
platform: NodeJS.Platform,
homeDir: string,
xdgConfigHome?: string,
): MpvInstallPaths {
const mpvConfigDir =
platform === 'darwin'
? path.join(homeDir, 'Library', 'Application Support', 'mpv')
: platform === 'linux'
? path.join((xdgConfigHome?.trim() || path.join(homeDir, '.config')), 'mpv')
: path.join(homeDir, 'AppData', 'Roaming', 'mpv');
return {
supported: platform === 'linux' || platform === 'darwin',
mpvConfigDir,
scriptsDir: path.join(mpvConfigDir, 'scripts'),
scriptOptsDir: path.join(mpvConfigDir, 'script-opts'),
pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'),
pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
};
}

View File

@@ -114,7 +114,13 @@ export interface WebSocketConfig {
port?: number;
}
export interface AnnotationWebSocketConfig {
enabled?: boolean;
port?: number;
}
export interface TexthookerConfig {
launchAtStartup?: boolean;
openBrowser?: boolean;
}
@@ -398,6 +404,16 @@ export interface JimakuConfig {
export type AnilistCharacterDictionaryEvictionPolicy = 'disable' | 'delete';
export type AnilistCharacterDictionaryProfileScope = 'all' | 'active';
export type AnilistCharacterDictionaryCollapsibleSectionKey =
| 'description'
| 'characterInformation'
| 'voicedBy';
export interface AnilistCharacterDictionaryCollapsibleSectionsConfig {
description?: boolean;
characterInformation?: boolean;
voicedBy?: boolean;
}
export interface AnilistCharacterDictionaryConfig {
enabled?: boolean;
@@ -405,6 +421,7 @@ export interface AnilistCharacterDictionaryConfig {
maxLoaded?: number;
evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy;
profileScope?: AnilistCharacterDictionaryProfileScope;
collapsibleSections?: AnilistCharacterDictionaryCollapsibleSectionsConfig;
}
export interface AnilistConfig {
@@ -468,6 +485,7 @@ export interface Config {
subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[];
websocket?: WebSocketConfig;
annotationWebsocket?: AnnotationWebSocketConfig;
texthooker?: TexthookerConfig;
ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig;
@@ -493,6 +511,7 @@ export interface ResolvedConfig {
subtitlePosition: SubtitlePosition;
keybindings: Keybinding[];
websocket: Required<WebSocketConfig>;
annotationWebsocket: Required<AnnotationWebSocketConfig>;
texthooker: Required<TexthookerConfig>;
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
@@ -604,6 +623,7 @@ export interface ResolvedConfig {
maxLoaded: number;
evictionPolicy: AnilistCharacterDictionaryEvictionPolicy;
profileScope: AnilistCharacterDictionaryProfileScope;
collapsibleSections: Required<AnilistCharacterDictionaryCollapsibleSectionsConfig>;
};
};
jellyfin: {

View File

@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
// @ts-expect-error Vendored Yomitan translator has no local TypeScript declarations.
import { Translator } from '../vendor/yomitan/js/language/translator.js';
type SortableTermEntry = {
matchPrimaryReading: boolean;
maxOriginalTextLength: number;
textProcessorRuleChainCandidates: unknown[];
inflectionRuleChainCandidates: unknown[];
sourceTermExactMatchCount: number;
frequencyOrder: number;
dictionaryIndex: number;
score: number;
dictionaryAlias: string;
headwords: Array<{ term: string }>;
definitions: Array<{ dictionary: string }>;
};
type SortableDefinition = {
dictionary: string;
dictionaryAlias: string;
frequencyOrder: number;
dictionaryIndex: number;
score: number;
headwordIndices: number[];
index: number;
};
test('Translator prioritizes SubMiner term entries without changing dictionary index order', () => {
const translator = new Translator({});
const entries: SortableTermEntry[] = [
{
matchPrimaryReading: true,
maxOriginalTextLength: 4,
textProcessorRuleChainCandidates: [],
inflectionRuleChainCandidates: [],
sourceTermExactMatchCount: 1,
frequencyOrder: 0,
dictionaryIndex: 0,
score: 10,
dictionaryAlias: 'JMdict',
headwords: [{ term: 'アイリス' }],
definitions: [{ dictionary: 'JMdict' }],
},
{
matchPrimaryReading: true,
maxOriginalTextLength: 4,
textProcessorRuleChainCandidates: [],
inflectionRuleChainCandidates: [],
sourceTermExactMatchCount: 1,
frequencyOrder: 99,
dictionaryIndex: 99,
score: 1,
dictionaryAlias: 'SubMiner Character Dictionary',
headwords: [{ term: 'アイリス' }],
definitions: [{ dictionary: 'SubMiner Character Dictionary' }],
},
];
translator._sortTermDictionaryEntries(entries as unknown[]);
assert.equal(entries[0]?.dictionaryAlias, 'SubMiner Character Dictionary');
});
test('Translator prioritizes SubMiner definitions without changing dictionary index order', () => {
const translator = new Translator({});
const definitions: SortableDefinition[] = [
{
dictionary: 'JMdict',
dictionaryAlias: 'JMdict',
frequencyOrder: 0,
dictionaryIndex: 0,
score: 10,
headwordIndices: [0],
index: 0,
},
{
dictionary: 'SubMiner Character Dictionary',
dictionaryAlias: 'SubMiner Character Dictionary',
frequencyOrder: 99,
dictionaryIndex: 99,
score: 1,
headwordIndices: [0],
index: 1,
},
];
translator._sortTermDictionaryEntryDefinitions(definitions as unknown[]);
assert.equal(definitions[0]?.dictionaryAlias, 'SubMiner Character Dictionary');
});

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2024-2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/** @type {import('language').TextProcessor} */
export const removeApostrophedWords = {
name: 'Remove common apostrophed words',
description: 'dell\'Italia > Italia, c\'erano > erano',
process: (str) => [
str,
removeApostrophedWordsImpl(str),
],
};
/**
* @param {string} word
* @returns {string}
*/
export function removeApostrophedWordsImpl(word) {
return word.replace(/(l|dell|all|dall|nell|sull|coll|un|quest|quell|c|n)[']/g, '');
}

View File

@@ -25,6 +25,8 @@ import {getAllLanguageReadingNormalizers, getAllLanguageTextProcessors} from './
import {MultiLanguageTransformer} from './multi-language-transformer.js';
import {isCodePointChinese} from './zh/chinese.js';
const SUBMINER_DICTIONARY_TITLE_PREFIX = 'SubMiner Character Dictionary';
/**
* Class which finds term and kanji dictionary entries for text.
*/
@@ -1531,6 +1533,23 @@ export class Translator {
return Array.isArray(value) ? value : (typeof value === 'number' ? [value] : []);
}
/**
* @param {string|undefined} value
* @returns {boolean}
*/
_isSubMinerDictionary(value) {
return typeof value === 'string' && value.startsWith(SUBMINER_DICTIONARY_TITLE_PREFIX);
}
/**
* @param {string|undefined} dictionary
* @param {string|undefined} dictionaryAlias
* @returns {number}
*/
_getSubMinerDictionarySortBoost(dictionary, dictionaryAlias) {
return (this._isSubMinerDictionary(dictionary) || this._isSubMinerDictionary(dictionaryAlias)) ? 1 : 0;
}
// Kanji data
/**
@@ -2162,6 +2181,10 @@ export class Translator {
i = v2.sourceTermExactMatchCount - v1.sourceTermExactMatchCount;
if (i !== 0) { return i; }
// Prefer SubMiner character dictionary entries without changing user dictionary order.
i = this._getSubMinerDictionarySortBoost(v2.definitions[0]?.dictionary, v2.dictionaryAlias) - this._getSubMinerDictionarySortBoost(v1.definitions[0]?.dictionary, v1.dictionaryAlias);
if (i !== 0) { return i; }
// Sort by frequency order
i = v1.frequencyOrder - v2.frequencyOrder;
if (i !== 0) { return i; }
@@ -2205,8 +2228,12 @@ export class Translator {
* @returns {number}
*/
const compareFunction = (v1, v2) => {
// Prefer SubMiner character dictionary definitions without changing user dictionary order.
let i = this._getSubMinerDictionarySortBoost(v2.dictionary, v2.dictionaryAlias) - this._getSubMinerDictionarySortBoost(v1.dictionary, v1.dictionaryAlias);
if (i !== 0) { return i; }
// Sort by frequency order
let i = v1.frequencyOrder - v2.frequencyOrder;
i = v1.frequencyOrder - v2.frequencyOrder;
if (i !== 0) { return i; }
// Sort by dictionary order

570
vendor/yomitan/lib/zstd-wasm.js vendored Normal file
View File

@@ -0,0 +1,570 @@
// @ts-nocheck
// node_modules/@bokuweb/zstd-wasm/dist/web/zstd.js
var Module = typeof Module !== "undefined" ? Module : {};
var moduleOverrides = {};
var key;
for (key in Module) {
if (Module.hasOwnProperty(key)) {
moduleOverrides[key] = Module[key];
}
}
var arguments_ = [];
var err = Module["printErr"] || console.warn.bind(console);
for (key in moduleOverrides) {
if (moduleOverrides.hasOwnProperty(key)) {
Module[key] = moduleOverrides[key];
}
}
var quit_ = (status, toThrow) => {
throw toThrow;
};
moduleOverrides = null;
if (Module["arguments"])
arguments_ = Module["arguments"];
if (Module["thisProgram"])
thisProgram = Module["thisProgram"];
if (Module["quit"])
quit_ = Module["quit"];
if (typeof WebAssembly !== "object") {
abort("no native wasm support detected");
}
var wasmMemory;
var ABORT = false;
var EXITSTATUS;
var HEAPU8;
var HEAP8;
function updateMemoryViews() {
var b = wasmMemory.buffer;
Module["HEAP8"] = HEAP8 = new Int8Array(b);
Module["HEAPU8"] = HEAPU8 = new Uint8Array(b);
}
var __ATPRERUN__ = [];
var __ATINIT__ = [];
var __ATPOSTRUN__ = [];
var runtimeInitialized = false;
function preRun() {
if (Module["preRun"]) {
if (typeof Module["preRun"] == "function")
Module["preRun"] = [Module["preRun"]];
while (Module["preRun"].length) {
addOnPreRun(Module["preRun"].shift());
}
}
callRuntimeCallbacks(__ATPRERUN__);
}
function initRuntime() {
runtimeInitialized = true;
callRuntimeCallbacks(__ATINIT__);
}
function postRun() {
if (Module["postRun"]) {
if (typeof Module["postRun"] == "function")
Module["postRun"] = [Module["postRun"]];
while (Module["postRun"].length) {
addOnPostRun(Module["postRun"].shift());
}
}
callRuntimeCallbacks(__ATPOSTRUN__);
}
function addOnPreRun(cb) {
__ATPRERUN__.unshift(cb);
}
function addOnInit(cb) {
__ATINIT__.unshift(cb);
}
function addOnPostRun(cb) {
__ATPOSTRUN__.unshift(cb);
}
var runDependencies = 0;
var dependenciesFulfilled = null;
function addRunDependency(id) {
var _a;
runDependencies++;
(_a = Module["monitorRunDependencies"]) === null || _a === void 0 ? void 0 : _a.call(Module, runDependencies);
}
function removeRunDependency(id) {
var _a;
runDependencies--;
(_a = Module["monitorRunDependencies"]) === null || _a === void 0 ? void 0 : _a.call(Module, runDependencies);
if (runDependencies == 0) {
if (dependenciesFulfilled) {
var callback = dependenciesFulfilled;
dependenciesFulfilled = null;
callback();
}
}
}
function abort(what) {
var _a;
(_a = Module["onAbort"]) === null || _a === void 0 ? void 0 : _a.call(Module, what);
what = "Aborted(" + what + ")";
err(what);
ABORT = true;
what += ". Build with -sASSERTIONS for more info.";
var e = new WebAssembly.RuntimeError(what);
throw e;
}
function getWasmImports() {
return { a: wasmImports };
}
function getBinaryPromise(url) {
return fetch(url, { credentials: "same-origin" }).then(function(response) {
if (!response["ok"]) {
throw "failed to load wasm binary file at '" + url + "'";
}
return response["arrayBuffer"]();
});
}
function init(filePathOrBuf) {
var info = getWasmImports();
function receiveInstance(instance, module) {
wasmExports = instance.exports;
wasmMemory = wasmExports["f"];
updateMemoryViews();
addOnInit(wasmExports["g"]);
removeRunDependency("wasm-instantiate");
return wasmExports;
}
addRunDependency("wasm-instantiate");
function receiveInstantiationResult(result) {
receiveInstance(result["instance"]);
}
function instantiateArrayBuffer(receiver) {
return getBinaryPromise(filePathOrBuf).then(function(binary) {
var result = WebAssembly.instantiate(binary, info);
return result;
}).then(receiver, function(reason) {
err("failed to asynchronously prepare wasm: " + reason);
abort(reason);
});
}
function instantiateAsync() {
if (filePathOrBuf && filePathOrBuf.byteLength > 0) {
return WebAssembly.instantiate(filePathOrBuf, info).then(receiveInstantiationResult, function(reason) {
err("wasm compile failed: " + reason);
});
} else if (typeof WebAssembly.instantiateStreaming === "function" && typeof filePathOrBuf === "string" && typeof fetch === "function") {
return fetch(filePathOrBuf, { credentials: "same-origin" }).then(function(response) {
var result = WebAssembly.instantiateStreaming(response, info);
return result.then(receiveInstantiationResult, function(reason) {
err("wasm streaming compile failed: " + reason);
err("falling back to ArrayBuffer instantiation");
return instantiateArrayBuffer(receiveInstantiationResult);
});
});
} else {
return instantiateArrayBuffer(receiveInstantiationResult);
}
}
if (Module["instantiateWasm"]) {
try {
var exports = Module["instantiateWasm"](info, receiveInstance);
return exports;
} catch (e) {
err("Module.instantiateWasm callback failed with error: " + e);
return false;
}
}
instantiateAsync();
return {};
}
var ExitStatus = class {
constructor(status) {
this.name = "ExitStatus";
this.message = `Program terminated with exit(${status})`;
this.status = status;
}
};
var callRuntimeCallbacks = (callbacks) => {
while (callbacks.length > 0) {
callbacks.shift()(Module);
}
};
var noExitRuntime = Module["noExitRuntime"] || true;
var __abort_js = () => abort("");
var runtimeKeepaliveCounter = 0;
var __emscripten_runtime_keepalive_clear = () => {
noExitRuntime = false;
runtimeKeepaliveCounter = 0;
};
var timers = {};
var handleException = (e) => {
if (e instanceof ExitStatus || e == "unwind") {
return EXITSTATUS;
}
quit_(1, e);
};
var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0;
var _proc_exit = (code) => {
var _a;
EXITSTATUS = code;
if (!keepRuntimeAlive()) {
(_a = Module["onExit"]) === null || _a === void 0 ? void 0 : _a.call(Module, code);
ABORT = true;
}
quit_(code, new ExitStatus(code));
};
var exitJS = (status, implicit) => {
EXITSTATUS = status;
_proc_exit(status);
};
var _exit = exitJS;
var maybeExit = () => {
if (!keepRuntimeAlive()) {
try {
_exit(EXITSTATUS);
} catch (e) {
handleException(e);
}
}
};
var callUserCallback = (func) => {
if (ABORT) {
return;
}
try {
func();
maybeExit();
} catch (e) {
handleException(e);
}
};
var _emscripten_get_now = () => performance.now();
var __setitimer_js = (which, timeout_ms) => {
if (timers[which]) {
clearTimeout(timers[which].id);
delete timers[which];
}
if (!timeout_ms)
return 0;
var id = setTimeout(() => {
delete timers[which];
callUserCallback(() => __emscripten_timeout(which, _emscripten_get_now()));
}, timeout_ms);
timers[which] = { id, timeout_ms };
return 0;
};
var getHeapMax = () => 2147483648;
var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment;
var growMemory = (size) => {
var b = wasmMemory.buffer;
var pages = (size - b.byteLength + 65535) / 65536 | 0;
try {
wasmMemory.grow(pages);
updateMemoryViews();
return 1;
} catch (e) {
}
};
var _emscripten_resize_heap = (requestedSize) => {
var oldSize = HEAPU8.length;
requestedSize >>>= 0;
var maxHeapSize = getHeapMax();
if (requestedSize > maxHeapSize) {
return false;
}
for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown);
overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296);
var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536));
var replacement = growMemory(newSize);
if (replacement) {
return true;
}
}
return false;
};
var wasmImports = {
c: __abort_js,
b: __emscripten_runtime_keepalive_clear,
d: __setitimer_js,
e: _emscripten_resize_heap,
a: _proc_exit
};
var wasmExports;
var _ZSTD_isError = Module["_ZSTD_isError"] = (a0) => (_ZSTD_isError = Module["_ZSTD_isError"] = wasmExports["h"])(a0);
var _ZSTD_compressBound = Module["_ZSTD_compressBound"] = (a0) => (_ZSTD_compressBound = Module["_ZSTD_compressBound"] = wasmExports["i"])(a0);
var _ZSTD_createCCtx = Module["_ZSTD_createCCtx"] = () => (_ZSTD_createCCtx = Module["_ZSTD_createCCtx"] = wasmExports["j"])();
var _ZSTD_freeCCtx = Module["_ZSTD_freeCCtx"] = (a0) => (_ZSTD_freeCCtx = Module["_ZSTD_freeCCtx"] = wasmExports["k"])(a0);
var _ZSTD_compress_usingDict = Module["_ZSTD_compress_usingDict"] = (a0, a1, a2, a3, a4, a5, a6, a7) => (_ZSTD_compress_usingDict = Module["_ZSTD_compress_usingDict"] = wasmExports["l"])(a0, a1, a2, a3, a4, a5, a6, a7);
var _ZSTD_compress = Module["_ZSTD_compress"] = (a0, a1, a2, a3, a4) => (_ZSTD_compress = Module["_ZSTD_compress"] = wasmExports["m"])(a0, a1, a2, a3, a4);
var _ZSTD_createDCtx = Module["_ZSTD_createDCtx"] = () => (_ZSTD_createDCtx = Module["_ZSTD_createDCtx"] = wasmExports["n"])();
var _ZSTD_freeDCtx = Module["_ZSTD_freeDCtx"] = (a0) => (_ZSTD_freeDCtx = Module["_ZSTD_freeDCtx"] = wasmExports["o"])(a0);
var _ZSTD_getFrameContentSize = Module["_ZSTD_getFrameContentSize"] = (a0, a1) => (_ZSTD_getFrameContentSize = Module["_ZSTD_getFrameContentSize"] = wasmExports["p"])(a0, a1);
var _ZSTD_decompress_usingDict = Module["_ZSTD_decompress_usingDict"] = (a0, a1, a2, a3, a4, a5, a6) => (_ZSTD_decompress_usingDict = Module["_ZSTD_decompress_usingDict"] = wasmExports["q"])(a0, a1, a2, a3, a4, a5, a6);
var _ZSTD_decompress = Module["_ZSTD_decompress"] = (a0, a1, a2, a3) => (_ZSTD_decompress = Module["_ZSTD_decompress"] = wasmExports["r"])(a0, a1, a2, a3);
var _malloc = Module["_malloc"] = (a0) => (_malloc = Module["_malloc"] = wasmExports["s"])(a0);
var _free = Module["_free"] = (a0) => (_free = Module["_free"] = wasmExports["t"])(a0);
var __emscripten_timeout = (a0, a1) => (__emscripten_timeout = wasmExports["v"])(a0, a1);
var calledRun;
dependenciesFulfilled = function runCaller() {
if (!calledRun)
run();
if (!calledRun)
dependenciesFulfilled = runCaller;
};
function run() {
if (runDependencies > 0) {
return;
}
preRun();
if (runDependencies > 0) {
return;
}
function doRun() {
var _a;
if (calledRun)
return;
calledRun = true;
Module["calledRun"] = true;
if (ABORT)
return;
initRuntime();
(_a = Module["onRuntimeInitialized"]) === null || _a === void 0 ? void 0 : _a.call(Module);
postRun();
}
if (Module["setStatus"]) {
Module["setStatus"]("Running...");
setTimeout(() => {
setTimeout(() => Module["setStatus"](""), 1);
doRun();
}, 1);
} else {
doRun();
}
}
Module["run"] = run;
if (Module["preInit"]) {
if (typeof Module["preInit"] == "function")
Module["preInit"] = [Module["preInit"]];
while (Module["preInit"].length > 0) {
Module["preInit"].pop()();
}
}
Module["init"] = init;
// node_modules/@bokuweb/zstd-wasm/dist/web/module.js
var __awaiter = function(thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P ? value : new P(function(resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function(resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator["throw"](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var initialized = (() => new Promise((resolve) => {
Module.onRuntimeInitialized = resolve;
}))();
var waitInitialized = () => __awaiter(void 0, void 0, void 0, function* () {
yield initialized;
});
// node_modules/@bokuweb/zstd-wasm/dist/web/errors/index.js
var isError = (code) => {
const _isError = Module["_ZSTD_isError"];
return _isError(code);
};
// node_modules/@bokuweb/zstd-wasm/dist/web/simple/decompress.js
var getFrameContentSize = (src, size) => {
const getSize = Module["_ZSTD_getFrameContentSize"];
return getSize(src, size);
};
var decompress = (buf, opts = { defaultHeapSize: 1024 * 1024 }) => {
const malloc = Module["_malloc"];
const src = malloc(buf.byteLength);
Module.HEAP8.set(buf, src);
const contentSize = getFrameContentSize(src, buf.byteLength);
const size = contentSize === -1 ? opts.defaultHeapSize : contentSize;
const free = Module["_free"];
const heap = malloc(size);
try {
const _decompress = Module["_ZSTD_decompress"];
const sizeOrError = _decompress(heap, size, src, buf.byteLength);
if (isError(sizeOrError)) {
throw new Error(`Failed to compress with code ${sizeOrError}`);
}
const data = new Uint8Array(Module.HEAPU8.buffer, heap, sizeOrError).slice();
free(heap, size);
free(src, buf.byteLength);
return data;
} catch (e) {
free(heap, size);
free(src, buf.byteLength);
throw e;
}
};
// node_modules/@bokuweb/zstd-wasm/dist/web/simple/compress.js
var compressBound = (size) => {
const bound = Module["_ZSTD_compressBound"];
return bound(size);
};
var compress = (buf, level) => {
const bound = compressBound(buf.byteLength);
const malloc = Module["_malloc"];
const compressed = malloc(bound);
const src = malloc(buf.byteLength);
Module.HEAP8.set(buf, src);
const free = Module["_free"];
try {
const _compress = Module["_ZSTD_compress"];
const sizeOrError = _compress(compressed, bound, src, buf.byteLength, level !== null && level !== void 0 ? level : 3);
if (isError(sizeOrError)) {
throw new Error(`Failed to compress with code ${sizeOrError}`);
}
const data = new Uint8Array(Module.HEAPU8.buffer, compressed, sizeOrError).slice();
free(compressed, bound);
free(src, buf.byteLength);
return data;
} catch (e) {
free(compressed, bound);
free(src, buf.byteLength);
throw e;
}
};
// node_modules/@bokuweb/zstd-wasm/dist/web/simple/decompress_using_dict.js
var getFrameContentSize2 = (src, size) => {
const getSize = Module["_ZSTD_getFrameContentSize"];
return getSize(src, size);
};
var createDCtx = () => {
return Module["_ZSTD_createDCtx"]();
};
var freeDCtx = (dctx) => {
return Module["_ZSTD_freeDCtx"](dctx);
};
var decompressUsingDict = (dctx, buf, dict, opts = { defaultHeapSize: 1024 * 1024 }) => {
const malloc = Module["_malloc"];
const src = malloc(buf.byteLength);
Module.HEAP8.set(buf, src);
const pdict = malloc(dict.byteLength);
Module.HEAP8.set(dict, pdict);
const contentSize = getFrameContentSize2(src, buf.byteLength);
const size = contentSize === -1 ? opts.defaultHeapSize : contentSize;
const free = Module["_free"];
const heap = malloc(size);
try {
const _decompress = Module["_ZSTD_decompress_usingDict"];
const sizeOrError = _decompress(dctx, heap, size, src, buf.byteLength, pdict, dict.byteLength);
if (isError(sizeOrError)) {
throw new Error(`Failed to compress with code ${sizeOrError}`);
}
const data = new Uint8Array(Module.HEAPU8.buffer, heap, sizeOrError).slice();
free(heap, size);
free(src, buf.byteLength);
free(pdict, dict.byteLength);
return data;
} catch (e) {
free(heap, size);
free(src, buf.byteLength);
free(pdict, dict.byteLength);
throw e;
}
};
// node_modules/@bokuweb/zstd-wasm/dist/web/simple/compress_using_dict.js
var compressBound2 = (size) => {
const bound = Module["_ZSTD_compressBound"];
return bound(size);
};
var createCCtx = () => {
return Module["_ZSTD_createCCtx"]();
};
var freeCCtx = (cctx) => {
return Module["_ZSTD_freeCCtx"](cctx);
};
var compressUsingDict = (cctx, buf, dict, level) => {
const bound = compressBound2(buf.byteLength);
const malloc = Module["_malloc"];
const compressed = malloc(bound);
const src = malloc(buf.byteLength);
Module.HEAP8.set(buf, src);
const pdict = malloc(dict.byteLength);
Module.HEAP8.set(dict, pdict);
const free = Module["_free"];
try {
const _compress = Module["_ZSTD_compress_usingDict"];
const sizeOrError = _compress(cctx, compressed, bound, src, buf.byteLength, pdict, dict.byteLength, level !== null && level !== void 0 ? level : 3);
if (isError(sizeOrError)) {
throw new Error(`Failed to compress with code ${sizeOrError}`);
}
const data = new Uint8Array(Module.HEAPU8.buffer, compressed, sizeOrError).slice();
free(compressed, bound);
free(src, buf.byteLength);
free(pdict, dict.byteLength);
return data;
} catch (e) {
free(compressed, bound);
free(src, buf.byteLength);
free(pdict, dict.byteLength);
throw e;
}
};
// node_modules/@bokuweb/zstd-wasm/dist/web/index.web.js
var __awaiter2 = function(thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P ? value : new P(function(resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function(resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator["throw"](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var init2 = (path) => __awaiter2(void 0, void 0, void 0, function* () {
const url = new URL(`./zstd.wasm`, import.meta.url).href;
Module["init"](path !== null && path !== void 0 ? path : url);
yield waitInitialized();
});
export {
compress,
compressUsingDict,
createCCtx,
createDCtx,
decompress,
decompressUsingDict,
freeCCtx,
freeDCtx,
init2 as init
};
//# sourceMappingURL=zstd-wasm.js.map

7
vendor/yomitan/lib/zstd-wasm.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,120 +0,0 @@
{
"manifest_version": 3,
"name": "Yomitan Popup Dictionary",
"version": "25.9.29.0",
"description": "Popup dictionary for language learning",
"key": "likgccmbimhjbgkjambclfkhldnlhbnn",
"author": {
"email": "themoeway@googlegroups.com"
},
"icons": {
"16": "images/icon16.png",
"19": "images/icon19.png",
"32": "images/icon32.png",
"38": "images/icon38.png",
"48": "images/icon48.png",
"64": "images/icon64.png",
"128": "images/icon128.png"
},
"action": {
"default_icon": {
"16": "images/icon16.png",
"19": "images/icon19.png",
"32": "images/icon32.png",
"38": "images/icon38.png",
"48": "images/icon48.png",
"64": "images/icon64.png",
"128": "images/icon128.png"
},
"default_title": "Yomitan",
"default_popup": "action-popup.html"
},
"background": {
"service_worker": "sw.js",
"type": "module"
},
"omnibox": {
"keyword": "yomi"
},
"content_scripts": [
{
"run_at": "document_idle",
"matches": [
"http://*/*",
"https://*/*",
"file://*/*"
],
"match_about_blank": true,
"all_frames": true,
"js": [
"js/app/content-script-wrapper.js"
]
}
],
"minimum_chrome_version": "102.0.0.0",
"options_ui": {
"page": "settings.html",
"open_in_tab": true
},
"sandbox": {
"pages": [
"template-renderer.html"
]
},
"permissions": [
"storage",
"clipboardWrite",
"unlimitedStorage",
"declarativeNetRequest",
"scripting",
"offscreen",
"contextMenus"
],
"optional_permissions": [
"clipboardRead",
"nativeMessaging"
],
"host_permissions": [
"<all_urls>"
],
"commands": {
"toggleTextScanning": {
"suggested_key": {
"default": "Alt+Delete"
},
"description": "Toggle text scanning on/off"
},
"openInfoPage": {
"description": "Open the info page"
},
"openSettingsPage": {
"description": "Open the settings page"
},
"openSearchPage": {
"suggested_key": {
"default": "Alt+Insert"
},
"description": "Open the search page"
},
"openPopupWindow": {
"description": "Open the popup window"
}
},
"web_accessible_resources": [
{
"resources": [
"popup.html",
"template-renderer.html",
"js/*",
"lib/resvg.wasm"
],
"matches": [
"<all_urls>"
]
}
],
"content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; img-src blob: 'self'; style-src 'self' 'unsafe-inline'; media-src *; connect-src *",
"sandbox": "sandbox allow-scripts; default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'unsafe-inline'"
}
}