mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-11 04:19:26 -07:00
Compare commits
7 Commits
94abd0f372
...
5d96f9d535
| Author | SHA1 | Date | |
|---|---|---|---|
|
5d96f9d535
|
|||
|
1d76e05cd3
|
|||
|
3dff6c2515
|
|||
|
755c1175b0
|
|||
|
78cd99a2d0
|
|||
|
1fd3f0575b
|
|||
|
ca0eec568c
|
33
README.md
33
README.md
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
107
launcher/setup-gate.test.ts
Normal 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
41
launcher/setup-gate.ts
Normal 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';
|
||||
}
|
||||
14
package.json
14
package.json
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -92,6 +92,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
maxLoaded: 3,
|
||||
evictionPolicy: 'delete',
|
||||
profileScope: 'all',
|
||||
collapsibleSections: {
|
||||
description: false,
|
||||
characterInformation: false,
|
||||
voicedBy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
settings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
showVisibleOverlay: false,
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('&', '&')
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
27
src/core/services/texthooker.test.ts
Normal file
27
src/core/services/texthooker.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
'になれば',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
82
src/generate-config-example.test.ts
Normal file
82
src/generate-config-example.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
229
src/main.ts
229
src/main.ts
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 Journey’s 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 Journey’s 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 Journey’s 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;
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -24,6 +24,7 @@ function createDeps() {
|
||||
isOverlayInitialized: () => true,
|
||||
initializeOverlay: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetup: () => {},
|
||||
setVisibleOverlay: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,7 +29,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
103
src/main/runtime/first-run-setup-plugin.test.ts
Normal file
103
src/main/runtime/first-run-setup-plugin.test.ts
Normal 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);
|
||||
});
|
||||
100
src/main/runtime/first-run-setup-plugin.ts
Normal file
100
src/main/runtime/first-run-setup-plugin.ts
Normal 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}.`,
|
||||
};
|
||||
}
|
||||
174
src/main/runtime/first-run-setup-service.test.ts
Normal file
174
src/main/runtime/first-run-setup-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
222
src/main/runtime/first-run-setup-service.ts
Normal file
222
src/main/runtime/first-run-setup-service.ts
Normal 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()),
|
||||
};
|
||||
}
|
||||
77
src/main/runtime/first-run-setup-window.test.ts
Normal file
77
src/main/runtime/first-run-setup-window.test.ts
Normal 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']);
|
||||
});
|
||||
329
src/main/runtime/first-run-setup-window.ts
Normal file
329
src/main/runtime/first-run-setup-window.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
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));
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlay = visible;
|
||||
},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
95
src/shared/setup-state.test.ts
Normal file
95
src/shared/setup-state.test.ts
Normal 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
192
src/shared/setup-state.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
20
src/types.ts
20
src/types.ts
@@ -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: {
|
||||
|
||||
93
src/yomitan-translator-sort.test.ts
Normal file
93
src/yomitan-translator-sort.test.ts
Normal 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');
|
||||
});
|
||||
35
vendor/yomitan/js/language/it/italian-processors.js
vendored
Normal file
35
vendor/yomitan/js/language/it/italian-processors.js
vendored
Normal 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, '');
|
||||
}
|
||||
29
vendor/yomitan/js/language/translator.js
vendored
29
vendor/yomitan/js/language/translator.js
vendored
@@ -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
570
vendor/yomitan/lib/zstd-wasm.js
vendored
Normal 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
7
vendor/yomitan/lib/zstd-wasm.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
120
vendor/yomitan/manifest_static.json
vendored
120
vendor/yomitan/manifest_static.json
vendored
@@ -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'"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user