mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 17:16:19 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca673735ba
|
@@ -20,8 +20,6 @@ coverage/
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
/build/hachidori/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -8,7 +8,3 @@
|
||||
[submodule "vendor/subminer-yomitan"]
|
||||
path = vendor/subminer-yomitan
|
||||
url = https://github.com/ksyasuda/subminer-yomitan
|
||||
[submodule "vendor/hachidori"]
|
||||
path = vendor/hachidori
|
||||
url = https://github.com/ksyasuda/hachidori.git
|
||||
branch = subminer
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -25,8 +25,6 @@ Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and
|
||||
|
||||
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
|
||||
|
||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
</div>
|
||||
@@ -277,8 +275,7 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
type: added
|
||||
area: dictionary
|
||||
|
||||
- Added a bundled Hachidori dictionary backend alongside the default Yomitan backend. Select it with `dictionaryBackend` and restart SubMiner.
|
||||
- The tray and dictionary-settings shortcut follow the selected backend. `--hachidori` opens Hachidori settings, while `--yomitan` continues to open Yomitan settings. With Hachidori active, an inactive external Yomitan profile does not block the bundled Yomitan settings.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend. Linked Docker hosts receive character dictionary uploads through `hachidori.externalHostManagementUrl`, retry busy imports, and replace the previous dictionary only after a successful import.
|
||||
- Hachidori auto-populates its first Anki template from SubMiner's deck, tags, and field mappings, detects an unambiguous matching note type, and preserves existing custom templates. Anki discovery retries after an unavailable connection.
|
||||
- Hachidori saves downloadable word audio before sending a note through SubMiner's Anki proxy, so freshly mined animated cards include the word-audio delay.
|
||||
- Both bundled dictionary backends reload their background code on startup so extension updates take effect while preserving installed dictionaries and settings. Failed Yomitan connection-setting updates can be retried without manually resetting the managed Anki endpoint.
|
||||
- First-run setup remembers each backend that finished it, including when reopened for legacy plugin cleanup, so switching back does not repeat setup, and the launcher gates playback on the backend the running app started with. Current incomplete or cancelled setup takes precedence over stale completion history. A running Yomitan session keeps using its external profile until it restarts into Hachidori.
|
||||
- Stats dashboard mining and deck lookup use the selected backend, with the Anki proxy enabled or disabled. Dashboard cards retain the selected history line and media instead of being processed again with the current mpv subtitle, with both Yomitan and Hachidori. Stats cards carry a `SubMiner::Stats` tag so polling also preserves their context. Hachidori word mining supplies dictionary aliases, IDs, and frequency metadata to its native Anki renderer. Settings labels for popup pause and the dictionary deck no longer name Yomitan, and the backend selector sits with the other dictionary settings.
|
||||
- Hachidori scans, dictionary counts, and settings reads wait for the dictionary engine to finish loading or importing instead of caching empty results.
|
||||
- First-run setup can link an external Hachidori dictionary host in an app, browser, or Docker container, verify its library, and unlink back to local dictionaries. The optional host controls are collapsed by default and explain which apps or containers must stay running. Unresponsive connection checks time out so setup remains usable. Anki mining, media enrichment, and custom toolbar buttons stay in SubMiner, including when editing buttons while linked.
|
||||
- Hachidori frequency highlighting reuses dictionary-entry ranks and fills missing ranks through its existing API. Entries without a matching definition may remain unranked. The bundled integration is maintained in a pinned fork submodule using upstream HoshiDicts and WASM binaries.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: dictionary
|
||||
|
||||
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: config
|
||||
|
||||
- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added an optional Catppuccin subtitle selection modal for primary and secondary mpv tracks. Enable it in Settings under Behavior, then press g followed by s. Disabling it restores mpv's subtitle selection binding.
|
||||
- Single-key actions take priority over configured sequence prefixes. Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
+7
-13
@@ -7,20 +7,13 @@
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
|
||||
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
@@ -239,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
|
||||
@@ -21,14 +21,6 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
|
||||
|
||||
### Reusing SubMiner settings in Hachidori
|
||||
|
||||
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It copies the configured deck and tags into untouched defaults, then fills missing word, sentence, pronunciation-audio, and picture mappings with fields that exist in Anki. Pronunciation uses `ankiConnect.fields.wordAudio`, falling back to `fields.audio` when no word-audio field is set.
|
||||
|
||||
If the note type is unset, SubMiner looks for a unique match containing its configured word and sentence fields. Enabled Lapis, Kiku, or Senren integration narrows the search; Lapis uses its configured model name. A fresh mapping also receives Hachidori's matching preset for readings, definitions, and other recognized fields. If several note types match, choose one in Hachidori Settings. If Anki is closed, open Hachidori Settings again after starting Anki to retry.
|
||||
|
||||
Existing custom decks, tags, field mappings, advanced templates, and additional templates stay intact. This fills missing settings rather than continually overwriting Hachidori choices. The Anki endpoint continues to follow SubMiner's proxy configuration. Sentence audio, image timing, translation, metadata, and duplicate field grouping remain controlled by SubMiner; pronunciation sources are configured in Hachidori. Linking an external dictionary host does not change this behavior.
|
||||
|
||||
## Auto-enrichment transport
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
|
||||
@@ -149,8 +141,6 @@ Field names are matched against your Anki note type case-insensitively (an exact
|
||||
|
||||
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
|
||||
|
||||
When Hachidori mines through SubMiner's Anki proxy, it prepares downloadable word audio before saving the note so the animation delay can be measured on the first mine. Configure a downloadable pronunciation source in Hachidori's Audio settings; browser speech cannot be saved into Anki by the SubMiner overlay. If pronunciation is unavailable, Hachidori reports a warning and the card has no word-audio hold.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
@@ -325,8 +315,6 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
|
||||
|
||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
|
||||
|
||||
Field grouping runs when a new note is added with known duplicates. With the Hachidori backend that is the popup's **Add anyway** choice; **Overwrite** updates the existing note in place and only receives media enrichment.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isKiku": {
|
||||
|
||||
@@ -171,14 +171,6 @@ The configuration file includes several main sections:
|
||||
|
||||
## Core settings
|
||||
|
||||
### Dictionary backend
|
||||
|
||||
`dictionaryBackend` accepts `"yomitan"` or `"hachidori"`. The default is defined in the [generated configuration example](/config.example.jsonc). Restart SubMiner after changing it. The tray shows the selected backend's settings, and the global dictionary-settings shortcut follows the same selection.
|
||||
|
||||
Each backend stores its own dictionaries and mining settings. `yomitan.externalProfilePath` applies only to Yomitan. See [Hachidori setup](./usage.md#hachidori-setup) before switching an existing installation.
|
||||
|
||||
`hachidori.externalHostManagementUrl` specifies the linked Docker host's HTTP(S) management origin for automatic character dictionary uploads and replacement. Use the management port, not the sharing or dictionary API port. See [Hachidori setup](./usage.md#hachidori-setup) for an example and [the generated configuration example](/config.example.jsonc) for the default.
|
||||
|
||||
### Logging
|
||||
|
||||
Control the minimum log level for runtime output:
|
||||
@@ -1125,6 +1117,14 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
||||
|
||||
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||
|
||||
## Subtitle Selection
|
||||
|
||||
Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
|
||||
|
||||
Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
|
||||
|
||||
`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
|
||||
|
||||
## External integrations
|
||||
|
||||
### Jimaku
|
||||
|
||||
@@ -152,14 +152,12 @@ Stats server config lives under `stats`:
|
||||
|
||||
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||
|
||||
- **Mine Word** - looks up the word with the selected dictionary backend, Yomitan or Hachidori, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. The selected history line supplies the card's context even while another subtitle is playing in mpv. Hachidori uses its configured Anki template, dictionary aliases, and frequency metadata. Requires Anki and the selected backend's dictionaries to be loaded.
|
||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||
|
||||
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
||||
|
||||
Stats cards also receive the `SubMiner::Stats` tag. SubMiner uses it to preserve their selected history context when detecting new cards through polling with the Anki proxy disabled.
|
||||
|
||||
Secondary subtitle text is stored alongside primary subtitles during playback, but the Search tab does not use it for display or matching.
|
||||
|
||||
### Word exclusion list
|
||||
|
||||
@@ -7,20 +7,13 @@
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Hachidori External Dictionary Imports
|
||||
// Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
|
||||
// Used only while Hachidori is linked to an external host.
|
||||
// ==========================================
|
||||
"hachidori": {
|
||||
"externalHostManagementUrl": "" // Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.
|
||||
}, // Configure the linked Docker host management URL, for example http://127.0.0.1:8780.
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
@@ -239,6 +232,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
|
||||
@@ -15,7 +15,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
| Shortcut | Action | Scope | Configurable |
|
||||
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | OS-global (registered with the OS) | Fixed (not configurable) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
|
||||
|
||||
::: tip
|
||||
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
|
||||
@@ -88,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
|
||||
@@ -98,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection).
|
||||
|
||||
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||
|
||||
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
|
||||
@@ -124,7 +127,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix - press `y
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `y-o` | Open active dictionary settings |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
+4
-27
@@ -15,8 +15,8 @@ That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socke
|
||||
Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> SubMiner requires at least one dictionary in the selected lookup backend.
|
||||
> See [Yomitan setup](#yomitan-setup) or [Hachidori setup](#hachidori-setup).
|
||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
||||
> See [Yomitan setup](#yomitan-setup) for details.
|
||||
|
||||
::: tip Anki card enrichment
|
||||
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
|
||||
@@ -174,7 +174,6 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle b
|
||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --hachidori # Open Hachidori settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
@@ -209,7 +208,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
|
||||
|
||||
### Windows mpv shortcut
|
||||
|
||||
First-run setup creates the config file, then requires dictionaries in the selected backend before it can finish.
|
||||
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
|
||||
|
||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||
@@ -315,28 +314,6 @@ For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yom
|
||||
|
||||
If you also use Yomitan in a browser, set that profile up separately. It inherits nothing from the bundled instance.
|
||||
|
||||
### Hachidori setup
|
||||
|
||||
Set `dictionaryBackend` to `"hachidori"` in SubMiner settings or `config.jsonc`, then restart SubMiner. The tray's dictionary settings entry changes to **Open Hachidori Settings**. Switching to `"yomitan"` restores the Yomitan entry after restarting.
|
||||
|
||||
Open Hachidori settings with `subminer app --hachidori` or `SubMiner.AppImage --hachidori`. Import your dictionary ZIPs or use Hachidori's recommended dictionary installer, then configure its Anki templates. Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not transfer to Hachidori.
|
||||
|
||||
First-run setup also offers **Dictionary source → Use an external dictionary host → Link host**. Enable sharing in the other Hachidori app or browser, or start a compatible Docker dictionary host, then enter its sharing address, such as `127.0.0.1:8771` or `ws://host:8771/link`. Use the WebSocket sharing port, not the management page or HTTP API port. The external host section is collapsed until you expand it or a host is linked. Browser hosts need the browser, Hachidori extension, and relay running. Electron hosts need the host app and any required relay running. Docker hosts need the container running; no browser needs to stay open.
|
||||
|
||||
Setup checks the host connection and dictionary inventory before enabling Finish. Import at least one dictionary on the host and refresh status. The link persists across restarts. **Unlink and use local dictionaries** restores SubMiner's local library. Anki templates, pronunciation sources, custom buttons, and SubMiner's audio/image processing remain local while linked. Dictionary settings and dictionary edits use the host. Frequency annotations use the frequencies returned with Hachidori dictionary entries. SubMiner keeps ranks found during scanning and queries the existing term-entry API for missing ranks. Words without a matching definition entry may remain unranked, even if a frequency dictionary contains them.
|
||||
|
||||
Both named settings flags work independently of the selected backend. Opening settings does not switch the overlay backend. The global dictionary-settings shortcut opens the selected backend.
|
||||
|
||||
Hachidori uses SubMiner's subtitle scanning, lookup counter, popup pause behavior, controller commands, character dictionaries, and Anki media enrichment. Keep SubMiner's AnkiConnect proxy enabled for screenshots and sentence audio. SubMiner routes Hachidori to that proxy when it is active; Hachidori's own screen recorder and screenshot capture are disabled in the embedded app.
|
||||
|
||||
For automatic character dictionary sync with a Docker host, set `hachidori.externalHostManagementUrl` to that same host's management origin, for example `"http://127.0.0.1:8780"`. This is separate from the WebSocket sharing address. SubMiner uploads the generated ZIP directly and replaces its previous dictionary after a successful import; busy imports are retried. Keep this URL pointed at the currently linked Docker host if you change hosts. An empty value disables external uploads and reports a configuration error when sync is attempted. Local Hachidori dictionaries do not need this setting. External browser/app hosts without the Docker management API do not support this automatic upload path.
|
||||
|
||||
Existing controls such as `startupWarmups.yomitanExtension` and `subtitleStyle.autoPauseVideoOnYomitanPopup` apply to the selected backend. Hachidori has one dictionary configuration, so character-dictionary profile scope applies to that configuration.
|
||||
|
||||
First-run setup remembers each backend that finished it, including when setup is reopened for legacy plugin cleanup. Switching to a backend for the first time asks for that backend's dictionaries; switching back to one that already finished does not repeat setup. Until SubMiner restarts, it keeps running the backend it started with, and the launcher gates playback on that running backend and logs a restart reminder. A running Yomitan session continues using its external profile until the restart. While Hachidori is active, `--yomitan` opens the bundled Yomitan settings even if an external Yomitan profile is configured.
|
||||
|
||||
Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well.
|
||||
|
||||
### YouTube playback
|
||||
|
||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
|
||||
@@ -431,7 +408,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
| Keybind | Action | Scope |
|
||||
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | OS-global - registered with the system, works from any window |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
|
||||
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
|
||||
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
@@ -44,12 +46,6 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
`vendor/hachidori/` is a submodule of `ksyasuda/hachidori`, tracking the `subminer` branch and pinned to a tested commit. Its nested HoshiDicts submodule and WASM binaries remain upstream versions. Initialize sources with `git submodule update --init --recursive`; merge upstream updates in the fork, test them, then update SubMiner's submodule commit. `SOURCE.json` records the upstream base and artifact checksums; the submodule commit identifies the integrated version. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. It enables overlay mode, disables custom JavaScript, and removes the unsupported `userScripts` permission only in that staged copy; the fork keeps upstream browser defaults. Before loading the extension, its session clears service worker registrations so Electron uses the current bundled code; dictionary databases and settings remain intact. First-run setup uses Hachidori sharing messages to link or unlink external dictionary hosts and checks their live inventory. Linked dictionaries and dictionary edits use the host, while Anki configuration, pronunciation sources, custom buttons, and mining stay local to SubMiner. The parser bridge adapts its runtime messages to the existing subtitle scanner and dictionary automation. Scanning retains term-entry frequencies, and only tokens without ranks need further frequency lookups through the existing term-entry API. This requires a matching definition entry and does not preserve the frequency source's reading provenance. SubMiner consumes native `hachidori-popup-shown` and `hachidori-popup-hidden` attention events for mouse handling, keyboard focus, and the subtitle sidebar. The fork retains host attributes, hover and successful-lookup notifications, and commands that need private reader state. The Anki proxy strips local duplicate/overwrite metadata before forwarding requests and enriches only confirmed writes.
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
|
||||
@@ -63,7 +63,6 @@ bun run docs:build
|
||||
Content checks and informational size reporting run inside electron-builder hooks. See the
|
||||
[release guide](../RELEASING.md#package-contents-and-size-checks) for size reports
|
||||
and the installed-app verification checklist.
|
||||
- Dictionary backend windows: after a full build, run `xvfb-run -a bun run test:dictionary:electron` on headless Linux. It uses temporary profiles to check both named settings commands, backend session isolation, the overlay's external-link bridge, and Hachidori's native dictionary parser.
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -26,10 +26,7 @@ import {
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import {
|
||||
hasLauncherExternalYomitanProfileConfig,
|
||||
loadLauncherDictionaryBackend,
|
||||
} from '../config.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -118,9 +115,6 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: loadLauncherDictionaryBackend(),
|
||||
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
|
||||
warn: (message) => log('warn', args.logLevel, message),
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
hasLegacyMpvPlugin: () =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { fail } from './log.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import type {
|
||||
Args,
|
||||
LauncherLoggingConfig,
|
||||
@@ -101,12 +100,6 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLauncherDictionaryBackend(): DictionaryBackend {
|
||||
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
|
||||
? 'hachidori'
|
||||
: 'yomitan';
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
@@ -241,10 +241,6 @@ export function applyRootOptionsToArgs(
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.yomitan === true || options.hachidori === true) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
|
||||
}
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
|
||||
@@ -86,8 +86,6 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('--yomitan', 'Open Yomitan settings window')
|
||||
.option('--hachidori', 'Open Hachidori settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
|
||||
+20
-73
@@ -282,31 +282,29 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
for (const flag of ['--settings', '--yomitan', '--hachidori']) {
|
||||
test(`launcher ${flag} forwards the matching app settings command`, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
test('launcher settings option forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher([flag], env);
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--settings'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
|
||||
});
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher settings command forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
@@ -1112,54 +1110,3 @@ test('classifyJellyfinChildSelection keeps container drilldown state instead of
|
||||
id: 'season-2',
|
||||
});
|
||||
});
|
||||
|
||||
test('external Yomitan profile remains available while a running app awaits a backend switch', () => {
|
||||
withTempDir((dir) => {
|
||||
const env = makeTestEnv(dir, path.join(dir, 'config'));
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: env.APPDATA,
|
||||
xdgConfigHome: env.XDG_CONFIG_HOME,
|
||||
homeDir: dir,
|
||||
existsSync: () => false,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
dictionaryBackend: 'hachidori',
|
||||
yomitan: { externalProfilePath: '/external/yomitan-profile' },
|
||||
}),
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'--eval',
|
||||
`
|
||||
import assert from 'node:assert/strict';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from './launcher/config.ts';
|
||||
import { ensureLauncherSetupReady } from './launcher/setup-gate.ts';
|
||||
import { createDefaultSetupState } from './src/shared/setup-state.ts';
|
||||
for (const running of [true, false]) {
|
||||
let launches = 0;
|
||||
let tick = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => running,
|
||||
readSetupState: () => ({ ...createDefaultSetupState(), dictionaryBackend: 'yomitan' }),
|
||||
isExternalYomitanConfigured: hasLauncherExternalYomitanProfileConfig,
|
||||
launchSetupApp: () => { launches += 1; },
|
||||
sleep: async () => {},
|
||||
now: () => tick++,
|
||||
timeoutMs: 2,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, running);
|
||||
assert.equal(launches, running ? 0 : 1);
|
||||
}
|
||||
`,
|
||||
],
|
||||
{ cwd: process.cwd(), env, encoding: 'utf8', timeout: LAUNCHER_RUN_TIMEOUT_MS },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,12 +344,3 @@ test('parseArgs requires an explicit logs action', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Logs command requires -e or --export/);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`parseArgs forwards ${flag} to the app`, () => {
|
||||
const parsed = parseArgs([flag], 'subminer', {});
|
||||
assert.equal(parsed.appPassthrough, true);
|
||||
assert.deepEqual(parsed.appArgs, [flag]);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
}
|
||||
|
||||
+2
-104
@@ -1,11 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ensureLauncherSetupReady,
|
||||
resolveLauncherGateBackend,
|
||||
waitForSetupCompletion,
|
||||
} from './setup-gate';
|
||||
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
|
||||
const commandLineSetupDefaults = {
|
||||
bunInstallStatus: 'unknown',
|
||||
@@ -299,101 +295,3 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
|
||||
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
|
||||
let launched = 0;
|
||||
let polls = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => state,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
launched += 1;
|
||||
},
|
||||
sleep: async () => {
|
||||
polls += 1;
|
||||
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
|
||||
},
|
||||
now: () => polls,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
assert.equal(launched, 1);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('matching Hachidori completion resumes playback without launching setup', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'completed',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('a backend that finished setup earlier passes the gate after switching back', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'yomitan',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'incomplete',
|
||||
completedDictionaryBackends: ['yomitan'],
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gate follows the running app backend until it restarts into the configured one', async () => {
|
||||
const warnings: string[] = [];
|
||||
const state = {
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'yomitan' as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
'yomitan',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => false,
|
||||
}),
|
||||
'hachidori',
|
||||
);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => true,
|
||||
readSetupState: () => state,
|
||||
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
+6
-47
@@ -1,13 +1,7 @@
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import {
|
||||
getSetupStateDictionaryBackend,
|
||||
isSetupCompleted,
|
||||
type SetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -19,7 +13,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state, deps.dictionaryBackend)) {
|
||||
if (isSetupCompleted(state)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
@@ -40,7 +34,6 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -48,13 +41,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state, deps.dictionaryBackend) &&
|
||||
isSetupCompleted(state) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
@@ -69,34 +62,8 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
/**
|
||||
* The app pins its dictionary backend at startup while the config file can change
|
||||
* underneath it. When an app is already running, gate on the backend it recorded
|
||||
* in the setup state rather than the config value it has not restarted into.
|
||||
*/
|
||||
export async function resolveLauncherGateBackend(deps: {
|
||||
configuredBackend: DictionaryBackend;
|
||||
state: SetupState | null;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<DictionaryBackend> {
|
||||
const runningBackend = deps.state
|
||||
? getSetupStateDictionaryBackend(deps.state)
|
||||
: deps.configuredBackend;
|
||||
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
|
||||
return deps.configuredBackend;
|
||||
}
|
||||
deps.warn?.(
|
||||
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
|
||||
);
|
||||
return runningBackend;
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
@@ -106,12 +73,6 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
const dictionaryBackend = await resolveLauncherGateBackend({
|
||||
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
|
||||
state: initialState,
|
||||
isAppRunning: deps.isAppRunning,
|
||||
warn: deps.warn,
|
||||
});
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
@@ -123,7 +84,6 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
dictionaryBackend,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
@@ -135,18 +95,17 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
dictionaryBackend,
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
|
||||
+2
-8
@@ -12,7 +12,6 @@
|
||||
"get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test:dictionary:electron": "bun scripts/run-dictionary-settings-smoke.mjs && bun scripts/run-dictionary-settings-smoke.mjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||
@@ -22,7 +21,7 @@
|
||||
"build:launcher": "bun run scripts/build-launcher.ts",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
@@ -82,8 +81,7 @@
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
|
||||
"test:package": "bun scripts/run-package-smoke.mjs",
|
||||
"build:hachidori": "bun scripts/build-hachidori.mjs"
|
||||
"test:package": "bun scripts/run-package-smoke.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
@@ -234,10 +232,6 @@
|
||||
"from": "build/yomitan",
|
||||
"to": "yomitan"
|
||||
},
|
||||
{
|
||||
"from": "build/hachidori",
|
||||
"to": "hachidori"
|
||||
},
|
||||
{
|
||||
"from": "vendor/yomitan-jlpt-vocab",
|
||||
"to": "yomitan-jlpt-vocab"
|
||||
|
||||
@@ -91,6 +91,10 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function key_code_to_mpv_name(code)
|
||||
local first, second = code:match("^Key([A-Z])%-Key([A-Z])$")
|
||||
if first and second then
|
||||
return string.lower(first) .. "-" .. string.lower(second)
|
||||
end
|
||||
if KEY_NAME_MAP[code] then
|
||||
return KEY_NAME_MAP[code]
|
||||
end
|
||||
@@ -187,6 +191,110 @@ function M.create(ctx)
|
||||
return bindings
|
||||
end
|
||||
|
||||
-- Match letter strokes, including mpv's uppercase spelling for Shift.
|
||||
local function letter_key_signature(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local modifiers = {}
|
||||
while true do
|
||||
local modifier, rest = value:match("^([%a]+)%+(.+)$")
|
||||
if not modifier then
|
||||
break
|
||||
end
|
||||
modifier = string.lower(modifier)
|
||||
if not MODIFIER_MAP[modifier] then
|
||||
return nil
|
||||
end
|
||||
modifiers[modifier] = true
|
||||
value = rest
|
||||
end
|
||||
if not value:match("^[a-zA-Z]$") then
|
||||
return nil
|
||||
end
|
||||
if value:match("^[A-Z]$") then
|
||||
modifiers.shift = true
|
||||
end
|
||||
local parts = {}
|
||||
for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do
|
||||
if modifiers[modifier] then
|
||||
parts[#parts + 1] = modifier
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = string.lower(value)
|
||||
return table.concat(parts, "+")
|
||||
end
|
||||
|
||||
local function external_single_keys()
|
||||
local keys = {}
|
||||
local native = mp.get_property_native and mp.get_property_native("input-bindings") or {}
|
||||
for _, entry in ipairs(native or {}) do
|
||||
local signature = letter_key_signature(entry.key)
|
||||
if
|
||||
signature
|
||||
and type(entry.cmd) == "string"
|
||||
and type(entry.priority) == "number"
|
||||
and entry.priority >= 0
|
||||
then
|
||||
local owned = entry.owner == "subminer"
|
||||
or (
|
||||
entry.owner == nil
|
||||
and (
|
||||
entry.cmd:match("script%-binding%s+['\"]?subminer/")
|
||||
or entry.cmd:match("script%-message%s+['\"]?subminer%-")
|
||||
)
|
||||
)
|
||||
local previous = keys[signature]
|
||||
if
|
||||
not previous
|
||||
or entry.priority > previous.priority
|
||||
or (entry.priority == previous.priority and owned)
|
||||
then
|
||||
local command = entry.cmd:match("^%s*(.-)%s*$")
|
||||
local flags = {
|
||||
["no-osd"] = true,
|
||||
["osd-bar"] = true,
|
||||
["osd-msg"] = true,
|
||||
["osd-msg-bar"] = true,
|
||||
["osd-auto"] = true,
|
||||
["expand-properties"] = true,
|
||||
["raw"] = true,
|
||||
["repeatable"] = true,
|
||||
["nonrepeatable"] = true,
|
||||
["nonscalable"] = true,
|
||||
["async"] = true,
|
||||
["sync"] = true,
|
||||
}
|
||||
while true do
|
||||
local flag, rest = command:match("^(%S+)%s+(.+)$")
|
||||
if not flags[flag] then
|
||||
break
|
||||
end
|
||||
command = rest
|
||||
end
|
||||
keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" }
|
||||
end
|
||||
end
|
||||
end
|
||||
return keys
|
||||
end
|
||||
|
||||
local function sequence_conflict(binding, singles)
|
||||
local code = binding.key and binding.key.code
|
||||
local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$")
|
||||
if not prefix then
|
||||
return nil
|
||||
end
|
||||
local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {}
|
||||
for _, name in ipairs(names) do
|
||||
local existing = singles[letter_key_signature(name)]
|
||||
if existing and not existing.owned and not existing.ignored then
|
||||
return name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function normalize_cli_args(cli_args)
|
||||
if type(cli_args) ~= "table" then
|
||||
return nil
|
||||
@@ -391,17 +499,23 @@ function M.create(ctx)
|
||||
local next_binding_names = {}
|
||||
state.session_binding_generation = (state.session_binding_generation or 0) + 1
|
||||
local generation = state.session_binding_generation
|
||||
local singles = external_single_keys()
|
||||
|
||||
for index, binding in ipairs(artifact.bindings) do
|
||||
if not is_supported_binding(binding) then
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported session binding from artifact"
|
||||
)
|
||||
subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
|
||||
else
|
||||
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||
if key_names then
|
||||
local conflict = sequence_conflict(binding, singles)
|
||||
if conflict then
|
||||
local message = "Disabled sequence "
|
||||
.. tostring(binding.originalKey or binding.key.code)
|
||||
.. ": mpv already uses "
|
||||
.. conflict
|
||||
.. ". Single-key bindings take priority."
|
||||
subminer_log("warn", "session-bindings", message)
|
||||
show_osd(message)
|
||||
elseif key_names then
|
||||
for key_index, key_name in ipairs(key_names) do
|
||||
local name = "subminer-session-binding-"
|
||||
.. tostring(generation)
|
||||
@@ -418,7 +532,8 @@ function M.create(ctx)
|
||||
subminer_log(
|
||||
"warn",
|
||||
"session-bindings",
|
||||
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
|
||||
"Skipped unsupported key code from artifact: "
|
||||
.. tostring(binding.key and binding.key.code or "unknown")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = path.join(repoRoot, 'vendor', 'hachidori');
|
||||
const output = path.join(repoRoot, 'build', 'hachidori');
|
||||
const provenance = JSON.parse(fs.readFileSync(path.join(source, 'SOURCE.json'), 'utf8'));
|
||||
|
||||
// Upstream commits the engine binaries. Verify the pinned bytes before staging
|
||||
// so ordinary app builds need neither Emscripten nor network access.
|
||||
for (const [file, expected] of Object.entries(provenance.artifacts)) {
|
||||
const actual = createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(source, file)))
|
||||
.digest('hex');
|
||||
if (actual !== expected) throw new Error(`Hachidori artifact checksum mismatch: ${file}`);
|
||||
}
|
||||
const extension = path.join(source, 'extension');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'manifest.json'), 'utf8'));
|
||||
for (const file of [
|
||||
manifest.options_page,
|
||||
manifest.background.service_worker,
|
||||
'offscreen.html',
|
||||
...manifest.content_scripts.flatMap(({ js, css }) => [...js, ...css]),
|
||||
]) {
|
||||
if (!fs.existsSync(path.join(extension, file)))
|
||||
throw new Error(`Missing Hachidori asset: ${file}`);
|
||||
}
|
||||
fs.rmSync(output, { recursive: true, force: true });
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
fs.cpSync(extension, output, { recursive: true });
|
||||
// Host configuration belongs in the staged copy, leaving the fork usable in Chrome.
|
||||
const overlayPath = path.join(output, 'overlay-mode.js');
|
||||
let overlay = fs.readFileSync(overlayPath, 'utf8');
|
||||
for (const [original, replacement] of [
|
||||
['export const OVERLAY_MODE = false;', 'export const OVERLAY_MODE = true;'],
|
||||
['customJavaScript: !IS_FIREFOX,', 'customJavaScript: false,'],
|
||||
]) {
|
||||
if (!overlay.includes(original))
|
||||
throw new Error(`Hachidori host configuration changed upstream: ${original}`);
|
||||
overlay = overlay.replace(original, replacement);
|
||||
}
|
||||
fs.writeFileSync(overlayPath, overlay);
|
||||
manifest.permissions = manifest.permissions.filter((permission) => permission !== 'userScripts');
|
||||
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
|
||||
fs.copyFileSync(path.join(source, file), path.join(output, file));
|
||||
}
|
||||
process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`);
|
||||
@@ -1,37 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
test('Hachidori staging configures Electron without changing the fork source', async () => {
|
||||
const files = ['overlay-mode.js', 'manifest.json'];
|
||||
const source = (file: string) =>
|
||||
new URL(`../vendor/hachidori/extension/${file}`, import.meta.url);
|
||||
const before = files.map((file) => readFileSync(source(file), 'utf8'));
|
||||
|
||||
execFileSync(process.execPath, [
|
||||
fileURLToPath(new URL('./build-hachidori.mjs', import.meta.url)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((file) => readFileSync(source(file), 'utf8')),
|
||||
before,
|
||||
);
|
||||
const staged = await import(new URL('../build/hachidori/overlay-mode.js', import.meta.url).href);
|
||||
assert.equal(staged.OVERLAY_MODE, true);
|
||||
assert.equal(staged.HOST_CAPABILITIES.customJavaScript, false);
|
||||
const original = await import(source('overlay-mode.js').href);
|
||||
assert.equal(original.OVERLAY_MODE, false);
|
||||
assert.equal(original.HOST_CAPABILITIES.customJavaScript, true);
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL('../build/hachidori/manifest.json', import.meta.url), 'utf8'),
|
||||
);
|
||||
const originalManifest = JSON.parse(readFileSync(source('manifest.json'), 'utf8'));
|
||||
assert.deepEqual(manifest, {
|
||||
...originalManifest,
|
||||
permissions: originalManifest.permissions.filter(
|
||||
(permission: string) => permission !== 'userScripts',
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
// Exercises the real entry point with disposable Linux config and data directories.
|
||||
const { app, BrowserWindow, session, shell } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
if (process.platform !== 'linux')
|
||||
throw new Error('This app-entry smoke requires Linux XDG isolation.');
|
||||
const root = process.cwd();
|
||||
const backend = process.argv.includes('--backend=yomitan') ? 'yomitan' : 'hachidori';
|
||||
const profile = process.env.SUBMINER_DICTIONARY_SMOKE_DATA;
|
||||
assert(
|
||||
profile && fs.existsSync(profile),
|
||||
'Use bun run test:dictionary:electron for profile isolation',
|
||||
);
|
||||
process.env.XDG_CONFIG_HOME = profile;
|
||||
process.env.XDG_DATA_HOME = path.join(profile, 'data');
|
||||
fs.mkdirSync(path.join(profile, 'SubMiner'));
|
||||
fs.writeFileSync(
|
||||
path.join(profile, 'SubMiner', 'config.json'),
|
||||
JSON.stringify({
|
||||
dictionaryBackend: backend,
|
||||
// An inactive external Yomitan profile must not block bundled settings in Hachidori mode.
|
||||
yomitan: {
|
||||
externalProfilePath: backend === 'hachidori' ? path.join(profile, 'external-yomitan') : '',
|
||||
},
|
||||
mpv: { socketPath: path.join(profile, 'missing-mpv.sock') },
|
||||
ankiConnect: { enabled: false },
|
||||
startupWarmups: { lowPowerMode: true },
|
||||
discordPresence: { enabled: false },
|
||||
updates: { enabled: false },
|
||||
}),
|
||||
);
|
||||
const openedLinks = [];
|
||||
shell.openExternal = async (url) => {
|
||||
openedLinks.push(url);
|
||||
};
|
||||
app.setAppPath(root);
|
||||
app.getVersion = () => require(path.join(root, 'package.json')).version;
|
||||
process.env.SUBMINER_APP_LOG = path.join(profile, 'app.log');
|
||||
app.commandLine.appendSwitch('ozone-platform', 'x11');
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
process.argv = [process.execPath, root, '--hachidori', '--log-level', 'debug'];
|
||||
require(path.join(root, 'dist/main-entry.js'));
|
||||
const deadline = setTimeout(() => {
|
||||
console.error(
|
||||
'FAIL timeout',
|
||||
BrowserWindow.getAllWindows().map((w) => w.webContents.getURL()),
|
||||
);
|
||||
finish(1);
|
||||
}, 60000);
|
||||
function finish(exitCode) {
|
||||
clearTimeout(deadline);
|
||||
app.exit(exitCode);
|
||||
}
|
||||
(async () => {
|
||||
await app.whenReady();
|
||||
let window;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
window = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Hachidori') && w.isVisible(),
|
||||
);
|
||||
if (window) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(window, 'Hachidori settings window opens from actual app flag');
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
assert.match(window.webContents.getURL(), /settings.html/);
|
||||
const status = await window.webContents.executeJavaScript(
|
||||
`chrome.runtime.sendMessage({target:'hoshidicts-offscreen',type:'hd_status',requestId:'app-settings-smoke'})`,
|
||||
);
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.ready, true);
|
||||
|
||||
console.log(
|
||||
'PASS actual --hachidori startup, visible settings, isolated backend session, native engine ready',
|
||||
);
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--yomitan'], root);
|
||||
let yomi;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
yomi = BrowserWindow.getAllWindows().find(
|
||||
(w) => w.getTitle().includes('Yomitan') && w.isVisible(),
|
||||
);
|
||||
if (yomi) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(yomi, 'inactive --yomitan settings opens');
|
||||
assert.equal(yomi.webContents.session, session.defaultSession);
|
||||
assert.equal(window.webContents.session, session.fromPartition('persist:hachidori'));
|
||||
app.emit('second-instance', {}, [process.execPath, root, '--toggle-visible-overlay'], root);
|
||||
let overlay;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
overlay = BrowserWindow.getAllWindows().find((w) =>
|
||||
w.webContents.getURL().includes('/renderer/index.html?'),
|
||||
);
|
||||
if (overlay && !overlay.webContents.isLoading()) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
assert.ok(overlay, 'actual overlay initialized');
|
||||
assert.equal(
|
||||
overlay.webContents.session,
|
||||
backend === 'hachidori' ? session.fromPartition('persist:hachidori') : session.defaultSession,
|
||||
);
|
||||
const requestLink = (url) =>
|
||||
overlay.webContents.executeJavaScript(`new Promise((resolve,reject)=>{
|
||||
const timer=setTimeout(()=>reject(Error('No external link acknowledgment')),3000);
|
||||
window.addEventListener('hachidori-open-external-result',e=>{clearTimeout(timer);resolve(e.detail);},{once:true});
|
||||
window.dispatchEvent(new CustomEvent('hachidori-open-external',{detail:{requestId:'smoke-link',url:${JSON.stringify(url)}}}));
|
||||
})`);
|
||||
assert.equal((await requestLink('file:///tmp/private')).ok, false);
|
||||
assert.equal((await requestLink('https://example.com/word')).ok, backend === 'hachidori');
|
||||
assert.deepEqual(openedLinks, backend === 'hachidori' ? ['https://example.com/word'] : []);
|
||||
console.log(
|
||||
`PASS ${backend} overlay session, independent settings windows, real preload external link bridge`,
|
||||
);
|
||||
|
||||
finish(0);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
finish(1);
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
// Run after bun run build. This uses a temporary profile and small local ZIPs.
|
||||
const { app, BrowserWindow, protocol, session } = require('electron');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const parser = require(path.join(root, 'dist/core/services/tokenizer/yomitan-parser-runtime.js'));
|
||||
const { writeStoredZip } = require(path.join(root, 'dist/shared/stored-zip.js'));
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachidori-parser-'));
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'chrome-extension',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
bypassCSP: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
app.setPath('userData', profile);
|
||||
app.setAppPath(root);
|
||||
app.disableHardwareAcceleration();
|
||||
app.on('window-all-closed', () => {});
|
||||
const deadline = setTimeout(() => {
|
||||
console.error('Hachidori parser verification timed out', profile);
|
||||
app.exit(1);
|
||||
}, 120_000);
|
||||
|
||||
function fixture(name, title, bankName, entries, frequencyMode = 'rank-based') {
|
||||
const zipPath = path.join(profile, name + '.zip');
|
||||
writeStoredZip(zipPath, [
|
||||
{
|
||||
name: 'index.json',
|
||||
data: Buffer.from(JSON.stringify({ title, revision: '1', format: 3, frequencyMode })),
|
||||
},
|
||||
{ name: bankName, data: Buffer.from(JSON.stringify(entries)) },
|
||||
]);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
const targetSession = session.fromPartition('persist:hachidori-check');
|
||||
const extension = await targetSession.extensions.loadExtension(
|
||||
path.join(root, 'build/hachidori'),
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
// Keep a host window alive while the dictionary importer opens/closes its
|
||||
// temporary settings windows, matching the running app's window lifecycle.
|
||||
const host = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { session: targetSession, contextIsolation: true, nodeIntegration: false },
|
||||
});
|
||||
await host.loadURL(`chrome-extension://${extension.id}/settings.html`);
|
||||
let parserWindow = null;
|
||||
let readyPromise = null;
|
||||
let initPromise = null;
|
||||
const deps = {
|
||||
getYomitanExt: () => extension,
|
||||
getYomitanSession: () => targetSession,
|
||||
getYomitanParserWindow: () => parserWindow,
|
||||
setYomitanParserWindow: (value) => {
|
||||
parserWindow = value;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => readyPromise,
|
||||
setYomitanParserReadyPromise: (value) => {
|
||||
readyPromise = value;
|
||||
},
|
||||
getYomitanParserInitPromise: () => initPromise,
|
||||
setYomitanParserInitPromise: (value) => {
|
||||
initPromise = value;
|
||||
},
|
||||
};
|
||||
const errors = [];
|
||||
const logger = {
|
||||
error: (...args) => {
|
||||
errors.push(args);
|
||||
console.error(...args);
|
||||
},
|
||||
};
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:18766', deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Exercise discovery and revisioned options writes in the real extension,
|
||||
// without creating a note or changing the user's Anki collection.
|
||||
const fields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
|
||||
const metadataServer = http.createServer((request, response) => {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
request.on('end', () => {
|
||||
const message = JSON.parse(body);
|
||||
assert.equal(message.action, 'multi');
|
||||
const result = message.params.actions.map(({ action }) => ({
|
||||
result:
|
||||
action === 'deckNames'
|
||||
? ['Test Mining']
|
||||
: action === 'modelNames'
|
||||
? ['Custom Japanese']
|
||||
: fields,
|
||||
error: null,
|
||||
}));
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ result, error: null }));
|
||||
});
|
||||
});
|
||||
await new Promise((resolve) => metadataServer.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const url = `http://127.0.0.1:${metadataServer.address().port}`;
|
||||
const ankiConfig = {
|
||||
tags: ['SubMiner', 'Autofill'],
|
||||
fields: {
|
||||
word: 'Term',
|
||||
sentence: 'Context',
|
||||
wordAudio: 'Pronunciation',
|
||||
image: 'Image',
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer(url, deps, logger, {
|
||||
forceOverride: true,
|
||||
deck: 'Test Mining',
|
||||
ankiConfig,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const anki = await parserWindow.webContents.executeJavaScript(
|
||||
`(async () => (await chrome.storage.local.get('options')).options.anki)()`,
|
||||
);
|
||||
assert.equal(anki.model, 'Custom Japanese');
|
||||
assert.equal(anki.deck, 'Test Mining');
|
||||
assert.deepEqual(anki.tags, ankiConfig.tags);
|
||||
assert.equal(anki.fieldTemplates.Term.value, '{expression}');
|
||||
assert.equal(anki.fieldTemplates.Pronunciation.value, '{audio}');
|
||||
assert.equal(anki.fieldTemplates.Context.value, '{sentence}');
|
||||
assert.deepEqual(anki.templates[0].fieldTemplates, anki.fieldTemplates);
|
||||
console.log('Hachidori Anki auto-population passed with an empty dictionary library');
|
||||
} finally {
|
||||
await new Promise((resolve) => metadataServer.close(resolve));
|
||||
}
|
||||
const archives = [
|
||||
fixture('terms', 'SubMiner Test Terms', 'term_bank_1.json', [
|
||||
['食べる', 'たべる', '', 'v1', 0, ['to eat'], 1, ''],
|
||||
]),
|
||||
fixture('names', 'SubMiner Character Dictionary (AniList 1)', 'term_bank_1.json', [
|
||||
['ミナト', 'みなと', '', 'n', 0, ['name'], 1, ''],
|
||||
]),
|
||||
fixture('frequency', 'SubMiner Test Frequency', 'term_meta_bank_1.json', [
|
||||
['食べる', 'freq', { reading: 'たべる', frequency: 42 }],
|
||||
['頻度だけ', 'freq', { reading: 'ひんどだけ', frequency: 120 }],
|
||||
['頻度だけ', 'freq', { reading: 'べつのよみ', frequency: 250 }],
|
||||
['頻度だけ', 'freq', 17],
|
||||
]),
|
||||
fixture(
|
||||
'occurrences',
|
||||
'SubMiner Test Occurrences',
|
||||
'term_meta_bank_1.json',
|
||||
[['頻度だけ', 'freq', 9000]],
|
||||
'occurrence-based',
|
||||
),
|
||||
];
|
||||
for (const archive of archives)
|
||||
assert.equal(await parser.importYomitanDictionaryFromZip(archive, deps, logger), true);
|
||||
const dictionaries = await parser.getYomitanDictionaryInfo(deps, logger);
|
||||
assert.equal(dictionaries.length, 4);
|
||||
parser.clearYomitanParserCachesForWindow(parserWindow);
|
||||
const tokens = await parser.requestYomitanScanTokens('ミナト 食べた', deps, logger, {
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
});
|
||||
assert.equal(tokens[0].isNameMatch, true);
|
||||
assert.equal(tokens[1].headword, '食べる');
|
||||
assert.equal(tokens[1].reading, 'たべた');
|
||||
assert.equal(tokens[1].startPos, 4);
|
||||
assert.equal(tokens[1].endPos, 7);
|
||||
assert.equal(tokens[1].frequencyRank, 42);
|
||||
assert.deepEqual(tokens[1].wordClasses, ['v1']);
|
||||
const exact = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'たべる' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.equal(exact.length, 1);
|
||||
assert.equal(exact[0].frequency, 42);
|
||||
assert.equal(exact[0].reading, 'たべる');
|
||||
assert.equal(exact[0].hasReading, false);
|
||||
const otherReading = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'べつのよみ' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
// The shared frequency pipeline retries a missing reading as a term-only query.
|
||||
assert.equal(otherReading[0]?.frequency, 42);
|
||||
const unmatched = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '頻度だけ', reading: null }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.deepEqual(unmatched, []);
|
||||
assert.equal(await parser.getYomitanCurrentAnkiDeckName(deps, logger), 'Test Mining');
|
||||
assert.equal((await targetSession.extensions.getAllExtensions()).length, 1);
|
||||
assert.equal(
|
||||
await parser.syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger),
|
||||
true,
|
||||
);
|
||||
const directSettings = await parser.getYomitanSettingsFull(deps, logger);
|
||||
assert.equal(directSettings.profiles[0].options.anki.server, 'http://127.0.0.1:8765');
|
||||
const proxyState = await host.webContents.executeJavaScript(
|
||||
"chrome.storage.local.get('subminerAnkiProxyUrl')",
|
||||
);
|
||||
assert.equal(proxyState.subminerAnkiProxyUrl, null);
|
||||
for (const entry of await parser.getYomitanDictionaryInfo(deps, logger)) {
|
||||
assert.equal(await parser.deleteYomitanDictionaryByTitle(entry.title, deps, logger), true);
|
||||
}
|
||||
assert.deepEqual(await parser.getYomitanDictionaryInfo(deps, logger), []);
|
||||
assert.equal(errors.length, 0);
|
||||
console.log(
|
||||
'PASS Hachidori native import, scanner, character names, term-entry API frequencies, settings and removal',
|
||||
);
|
||||
clearTimeout(deadline);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(deadline);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import '../vendor/hachidori/extension/reader-options.js';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
|
||||
|
||||
async function mine({ proxy = true, audioFails = false } = {}) {
|
||||
const events: string[] = [];
|
||||
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
|
||||
let fields: Record<string, string> = {};
|
||||
let initialAudio = '';
|
||||
let stored = false;
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
const { action, params } = JSON.parse(String(options.body));
|
||||
events.push(action);
|
||||
let result: unknown;
|
||||
switch (action) {
|
||||
case 'multi':
|
||||
result = [
|
||||
{ result: ['Default'], error: null },
|
||||
{ result: ['Basic'], error: null },
|
||||
{ result: ['Expression', 'ExpressionAudio'], error: null },
|
||||
];
|
||||
break;
|
||||
case 'canAddNotes':
|
||||
result = [true];
|
||||
break;
|
||||
case 'canAddNotesWithErrorDetail':
|
||||
result = [{ canAdd: true, error: null }];
|
||||
break;
|
||||
case 'getMediaFilesNames':
|
||||
result = stored ? [filename] : [];
|
||||
break;
|
||||
case 'storeMediaFile':
|
||||
stored = true;
|
||||
result = filename;
|
||||
break;
|
||||
case 'addNote':
|
||||
fields = { ...params.note.fields };
|
||||
initialAudio = fields.ExpressionAudio ?? '';
|
||||
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
|
||||
result = 123;
|
||||
break;
|
||||
case 'notesInfo':
|
||||
result = [
|
||||
{
|
||||
noteId: 123,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, { value }]),
|
||||
),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'updateNoteFields':
|
||||
Object.assign(fields, params.note.fields);
|
||||
result = null;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected action: ${action}`);
|
||||
}
|
||||
return Response.json({ result, error: null });
|
||||
},
|
||||
});
|
||||
const service = createAnkiWorkerService({
|
||||
gateway,
|
||||
readOptions: async () => ({
|
||||
anki: {
|
||||
url: 'http://127.0.0.1:8766',
|
||||
apiKey: '',
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
deck: 'Default',
|
||||
model: 'Basic',
|
||||
tags: [],
|
||||
fields: {},
|
||||
duplicateScope: 'model',
|
||||
duplicateBehavior: 'prevent',
|
||||
captureScreenshot: false,
|
||||
fieldTemplates: {
|
||||
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
|
||||
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
audioSources: [
|
||||
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
|
||||
],
|
||||
mediaCapture: { enabled: false },
|
||||
}),
|
||||
readDictionaries: async () => [],
|
||||
engine: async () => ({ ready: true, loading: false, generation: 1 }),
|
||||
offscreen: async (message: { type: string; audio?: string }) => {
|
||||
if (message.type === 'hd_anki_audio') {
|
||||
events.push('pronunciation');
|
||||
if (audioFails) throw new Error('No pronunciation available');
|
||||
return { filename, data: 'YXVkaW8=' };
|
||||
}
|
||||
return { fields: { Expression: '猫', ExpressionAudio: message.audio ?? '' }, media: [] };
|
||||
},
|
||||
duplicateIndex: { source: async () => null, recordWrite: async () => {} },
|
||||
});
|
||||
const status = await service.status();
|
||||
assert.equal(status.available, true, status.error);
|
||||
const result = await service.submit({
|
||||
configKey: status.configKey,
|
||||
generation: 1,
|
||||
term: { expression: '猫', reading: 'ねこ' },
|
||||
});
|
||||
return { result, initialAudio, fields, events, filename };
|
||||
}
|
||||
|
||||
test('SubMiner receives pronunciation in the initial Hachidori note, before enrichment starts', async () => {
|
||||
const value = await mine();
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.equal(value.initialAudio, `[sound:${value.filename}]`);
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
});
|
||||
|
||||
test('direct Hachidori keeps deferred pronunciation', async () => {
|
||||
const value = await mine({ proxy: false });
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.fields.ExpressionAudio, `[sound:${value.filename}]`);
|
||||
});
|
||||
|
||||
test('unavailable pronunciation remains a warning without a late audio write', async () => {
|
||||
const value = await mine({ audioFails: true });
|
||||
assert.equal(value.result.state, 'added');
|
||||
assert.match(value.result.warnings.join(' '), /No pronunciation available/);
|
||||
assert.equal(value.initialAudio, '');
|
||||
assert.equal(value.events.filter((event) => event === 'pronunciation').length, 1);
|
||||
assert.equal(value.events.includes('updateNoteFields'), false);
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
|
||||
const bridge = readFileSync(
|
||||
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function run(code: string) {
|
||||
runInNewContext(`${bridge}\n${code}`, {
|
||||
window: new EventTarget(),
|
||||
EventTarget,
|
||||
CustomEvent,
|
||||
assert,
|
||||
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
|
||||
KeyboardEvent: class {
|
||||
constructor(_type: string, options: KeyboardEventInit = {}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Hachidori marks popup state independently from successful lookups', () => {
|
||||
run(`
|
||||
const events = [];
|
||||
for (const name of ['yomitan-popup-shown', 'yomitan-popup-hidden', 'subminer-yomitan-lookup']) {
|
||||
window.addEventListener(name, () => events.push(name));
|
||||
}
|
||||
const attributes = new Map();
|
||||
const host = { setAttribute: (name, value) => attributes.set(name, value) };
|
||||
SubMinerHachidori.markHost(host, true);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
|
||||
assert.equal(events.join(','), '');
|
||||
SubMinerHachidori.lookup();
|
||||
SubMinerHachidori.markHost(host, false);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
|
||||
assert.equal(events.join(','), 'subminer-yomitan-lookup');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori promotes character glossaries and results without overriding linguistic rank', () => {
|
||||
run(`
|
||||
const result = (id, matched, dictionary, options = {}) => ({
|
||||
matched, deinflected: matched, trace: [], preprocessorSteps: 0,
|
||||
term: { expression: matched, reading: 'reading', glossaries: [{ dictionary }] }, ...options, id,
|
||||
});
|
||||
const character = characterPrefix + ' - Current show';
|
||||
const longer = result('longer', '花子さん', 'General');
|
||||
const normal = result('normal', '花子', 'General');
|
||||
const person = result('person', '花子', character);
|
||||
const shorter = result('shorter', '花', character);
|
||||
const output = SubMinerHachidori.prioritizeCharacterResults([longer, normal, person, shorter]);
|
||||
assert.equal(output.map(entry => entry.id).join(','), 'longer,person,normal,shorter');
|
||||
const merged = result('merged', '花子', 'General');
|
||||
merged.term.glossaries.push({ dictionary: character }, { dictionary: 'Second general' });
|
||||
const [promoted] = SubMinerHachidori.prioritizeCharacterResults([merged]);
|
||||
assert.equal(promoted.term.glossaries.map(g => g.dictionary).join(','), character + ',General,Second general');
|
||||
assert.equal(merged.term.glossaries[0].dictionary, 'General');
|
||||
for (const change of [{ preprocessorSteps: 1 }, { trace: [{}] }, { deinflected: '別の語' }]) {
|
||||
const transformed = { ...person, ...change };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, transformed])[0].id, 'normal');
|
||||
}
|
||||
const preferredReading = { ...normal, term: { ...normal.term, reading: 'preferred' } };
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([person, preferredReading], {primaryReading: 'preferred'})[0].id, 'normal');
|
||||
const aliased = result('aliased', '花子', 'Imported character data');
|
||||
assert.equal(SubMinerHachidori.prioritizeCharacterResults([normal, aliased], {}, [{title: 'Imported character data', displayName: character}])[0].id, 'aliased');
|
||||
`);
|
||||
});
|
||||
|
||||
test('Hachidori sends private mining metadata only to the configured SubMiner proxy', async () => {
|
||||
const requests: Array<{ url: string; body: string }> = [];
|
||||
const proxy = 'http://127.0.0.1:8766';
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => proxy,
|
||||
fetch: async (url: string, options: RequestInit) => {
|
||||
assert.equal(typeof options.body, 'string');
|
||||
requests.push({ url, body: String(options.body) });
|
||||
return Response.json({ result: 123, error: null });
|
||||
},
|
||||
});
|
||||
const params = {
|
||||
note: { fields: { Expression: '花子' } },
|
||||
subminerDuplicateNoteIds: [456],
|
||||
subminerEnrich: true,
|
||||
};
|
||||
await gateway.invoke('addNote', params, '', 1000, proxy);
|
||||
await gateway.invoke('addNote', params, '', 1000, 'http://127.0.0.1:8765');
|
||||
assert.deepEqual(
|
||||
requests.map((request) => JSON.parse(request.body).params),
|
||||
[params, { note: params.note }],
|
||||
);
|
||||
assert.equal(params.subminerEnrich, true);
|
||||
const direct = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => null,
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
assert.deepEqual(JSON.parse(String(options.body)).params, { note: params.note });
|
||||
return Response.json({ result: null, error: null });
|
||||
},
|
||||
});
|
||||
await direct.invoke('updateNoteFields', params, '', 1000, proxy);
|
||||
});
|
||||
|
||||
test('Hachidori routes host commands, validates keyboard input and disconnects', () => {
|
||||
run(`
|
||||
const calls = [];
|
||||
const disconnect = SubMinerHachidori.connect({
|
||||
hide: () => calls.push('hide'), clear: () => calls.push('clear'),
|
||||
action: name => calls.push(name), cycleAudio: direction => calls.push(direction),
|
||||
scroll: (x, y) => calls.push(x + ':' + y),
|
||||
keydown: event => calls.push(event.key + ':' + event.ctrlKey + ':' + event.shiftKey),
|
||||
});
|
||||
const send = detail => window.dispatchEvent(new CustomEvent('subminer-yomitan-popup-command', {detail}));
|
||||
send(null);
|
||||
send({ type: 'forwardKeyDown', key: 12, modifiers: [] });
|
||||
send({ type: 'mineSelected' });
|
||||
send({ type: 'playCurrentAudio' });
|
||||
send({ type: 'scanSelectedText' });
|
||||
send({ type: 'cycleAudioSource', direction: -1 });
|
||||
send({ type: 'scrollBy', deltaX: Infinity, deltaY: 40 });
|
||||
send({ type: 'forwardKeyDown', key: 'j', code: 'KeyJ', modifiers: ['ctrl', 'shift'] });
|
||||
send({ type: 'setVisible', visible: false });
|
||||
send({ type: 'clearActiveTextSource' });
|
||||
disconnect();
|
||||
send({ type: 'mineSelected' });
|
||||
assert.equal(calls.join(','), 'addNote,playAudio,scanSelectedText,-1,0:40,j:true:true,hide,clear');
|
||||
`);
|
||||
});
|
||||
@@ -27,14 +27,6 @@ const REQUIRED_APP_FILES = [
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'hachidori/manifest.json',
|
||||
'hachidori/settings.html',
|
||||
'hachidori/subminer-host.js',
|
||||
'hachidori/vendor/hoshidicts.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded-idbfs.wasm',
|
||||
'hachidori/LICENSE',
|
||||
'hachidori/SOURCE.json',
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-hachi-settings-'));
|
||||
const env = { ...process.env, SUBMINER_DICTIONARY_SMOKE_DATA: profile };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[
|
||||
'--no-sandbox',
|
||||
fileURLToPath(new URL('./check-dictionary-backends.cjs', import.meta.url)),
|
||||
...process.argv.slice(2),
|
||||
],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
+10
-43
@@ -5,7 +5,6 @@ const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const assert = require('node:assert/strict');
|
||||
const { once } = require('node:events');
|
||||
const http = require('node:http');
|
||||
|
||||
const resources = path.resolve(process.argv[2]);
|
||||
const archive = path.join(resources, 'app.asar');
|
||||
@@ -50,16 +49,12 @@ async function smoke() {
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const hachidori = await session
|
||||
.fromPartition('persist:hachidori')
|
||||
.extensions.loadExtension(path.join(resources, 'hachidori'), { allowFileAccess: true });
|
||||
assert(hachidori.id, 'Hachidori extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
|
||||
if (details.error !== 'net::ERR_ABORTED')
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
});
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -68,50 +63,22 @@ async function smoke() {
|
||||
},
|
||||
});
|
||||
try {
|
||||
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
await win.loadFile(
|
||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
if (ui !== 'stats') {
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
}
|
||||
} finally {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
// The stats dashboard uses HTTP for both assets and API requests in the app.
|
||||
const { ImmersionTrackerService } = packagedRequire(
|
||||
'./dist/core/services/immersion-tracker-service.js',
|
||||
);
|
||||
const { createStatsApp, startNodeHttpServer } = packagedRequire(
|
||||
'./dist/core/services/stats-server.js',
|
||||
);
|
||||
const tracker = new ImmersionTrackerService({ dbPath: path.join(isolatedData, 'stats.db') });
|
||||
const statsConfig = { port: 0, staticDir: path.join(archive, 'stats/dist'), tracker };
|
||||
let statsHttp;
|
||||
const statsServer = await startNodeHttpServer(
|
||||
createStatsApp(tracker, statsConfig),
|
||||
statsConfig,
|
||||
(listener) => (statsHttp = http.createServer(listener)),
|
||||
);
|
||||
const statsWindow = new BrowserWindow({ show: false });
|
||||
try {
|
||||
const url = `http://127.0.0.1:${statsHttp.address().port}`;
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${url}/*`] }, (details) => {
|
||||
if (details.statusCode >= 400) failedRequests.push(`${details.url}: ${details.statusCode}`);
|
||||
});
|
||||
await statsWindow.loadURL(url);
|
||||
for (const endpoint of ['overview', 'sessions']) {
|
||||
const response = await fetch(`${url}/api/stats/${endpoint}`);
|
||||
assert.equal(response.status, 200, `Stats ${endpoint} request failed`);
|
||||
await response.json();
|
||||
}
|
||||
} finally {
|
||||
statsWindow.destroy();
|
||||
await statsServer.close();
|
||||
tracker.destroy();
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ local ctx = {
|
||||
return {
|
||||
numericSelectionTimeoutMs = 3000,
|
||||
bindings = {
|
||||
{
|
||||
key = { code = "KeyG-KeyS", modifiers = {} },
|
||||
actionType = "session-action",
|
||||
actionId = "openSubtitleSelection",
|
||||
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyO",
|
||||
@@ -312,7 +318,8 @@ local ctx = {
|
||||
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
nil
|
||||
end,
|
||||
},
|
||||
state = {
|
||||
@@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
|
||||
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
||||
|
||||
local character_dictionary_manager = find_binding("Ctrl+d")
|
||||
assert_true(
|
||||
character_dictionary_manager ~= nil,
|
||||
"character dictionary manager binding should be registered"
|
||||
)
|
||||
assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
|
||||
|
||||
character_dictionary_manager.fn()
|
||||
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
character_dictionary_manager_call ~= nil,
|
||||
"character dictionary manager binding should invoke CLI action"
|
||||
)
|
||||
assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
|
||||
assert_true(
|
||||
character_dictionary_manager_call[2] == "--session-action",
|
||||
"character dictionary manager binding should use generic session action CLI flag"
|
||||
@@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine
|
||||
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
|
||||
|
||||
print("plugin session binding regression tests: OK")
|
||||
|
||||
local selector = find_binding("g-s")
|
||||
assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence")
|
||||
selector.fn()
|
||||
local selection_call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(
|
||||
selection_call[3] == '{"actionId":"openSubtitleSelection"}',
|
||||
"subtitle selection should dispatch its session action"
|
||||
)
|
||||
|
||||
local native_bindings = {}
|
||||
function mp.get_property_native(name)
|
||||
assert_true(name == "input-bindings", "only native input bindings should be queried")
|
||||
return native_bindings
|
||||
end
|
||||
|
||||
for _, case in ipairs({
|
||||
{ key = "g", priority = 1, enabled = false },
|
||||
{ key = "G", priority = 1, enabled = true },
|
||||
{ key = "Shift+g", priority = 1, enabled = true },
|
||||
{ key = "Ctrl+g", priority = 1, enabled = true },
|
||||
{ key = "g", priority = -1, enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "ignore", enabled = true },
|
||||
{ key = "g", priority = 1, cmd = "no-osd ignore", enabled = true },
|
||||
}) do
|
||||
native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } }
|
||||
recorded.bindings = {}
|
||||
assert_true(bindings.reload_bindings(), "binding reload should succeed")
|
||||
assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key)
|
||||
end
|
||||
assert_true(#recorded.osd > 0, "native prefix conflicts should be visible")
|
||||
print("plugin sequence conflict tests: OK")
|
||||
|
||||
@@ -1793,9 +1793,6 @@ export class AnkiIntegration {
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
log.debug(
|
||||
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
|
||||
);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
|
||||
@@ -441,127 +441,6 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy enriches confirmed Hachidori overwrites without counting a new card or forwarding metadata', async () => {
|
||||
const received: unknown[] = [];
|
||||
let upstreamError: string | null = null;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: null, error: upstreamError }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
added.push(count);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const [id, marked, error] of [
|
||||
[51, true, null],
|
||||
[52, false, null],
|
||||
[53, true, 'failed'],
|
||||
] satisfies Array<[number, boolean, string | null]>) {
|
||||
upstreamError = error;
|
||||
await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'updateNoteFields',
|
||||
version: 6,
|
||||
params: {
|
||||
note: { id, fields: { Expression: '猫' } },
|
||||
...(marked ? { subminerEnrich: true } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
await waitForCondition(() => processed.length > 0);
|
||||
assert.deepEqual(processed, [51]);
|
||||
assert.deepEqual(added, []);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('stats-owned notes bypass overlay enrichment while popup notes still enqueue', async () => {
|
||||
const processed: number[] = [];
|
||||
const added: number[] = [];
|
||||
const received: unknown[] = [];
|
||||
let noteId = 70;
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(Buffer.from(chunk));
|
||||
received.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.end(JSON.stringify({ result: ++noteId, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (id) => {
|
||||
processed.push(id);
|
||||
},
|
||||
recordCardsAdded: (_count, ids) => {
|
||||
added.push(...ids);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
try {
|
||||
proxy.start({ host: '127.0.0.1', port: 0, upstreamUrl: `http://127.0.0.1:${address.port}` });
|
||||
await proxy.waitUntilReady();
|
||||
const server: unknown = Reflect.get(proxy, 'server');
|
||||
assert.ok(server instanceof http.Server);
|
||||
const bound = server.address();
|
||||
assert.ok(bound && typeof bound === 'object');
|
||||
for (const metadata of [{ subminerEnrich: false }, {}]) {
|
||||
const response: Response = await fetch(`http://127.0.0.1:${bound.port}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: { note: { fields: { Expression: '猫' } }, ...metadata },
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
await response.json();
|
||||
}
|
||||
await waitForCondition(() => processed.includes(72));
|
||||
assert.deepEqual(processed, [72]);
|
||||
assert.deepEqual(added, [71, 72]);
|
||||
assert.equal(JSON.stringify(received).includes('subminerEnrich'), false);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -224,31 +224,6 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('subminerEnrich' in params) ||
|
||||
params.subminerEnrich !== true
|
||||
)
|
||||
return;
|
||||
const note = 'note' in params ? params.note : null;
|
||||
if (!note || typeof note !== 'object' || !('id' in note)) return;
|
||||
const response = this.tryParseJsonValue(responseBody);
|
||||
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!('error' in response) ||
|
||||
response.error !== null ||
|
||||
!('result' in response) ||
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||
return;
|
||||
}
|
||||
@@ -264,23 +239,12 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
: this.collectNoteIdsForAction(action, responseResult);
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
action === 'addNote' &&
|
||||
params &&
|
||||
typeof params === 'object' &&
|
||||
'subminerEnrich' in params &&
|
||||
params.subminerEnrich === false
|
||||
) {
|
||||
// Stats owns the saved sentence and media; the live mpv context is unrelated.
|
||||
if (noteIds.length > 0) this.deps.recordCardsAdded?.(noteIds.length, noteIds);
|
||||
return;
|
||||
}
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
return;
|
||||
@@ -329,7 +293,7 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote' && action !== 'updateNoteFields') {
|
||||
if (action !== 'addNote') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
@@ -337,17 +301,12 @@ export class AnkiConnectProxyServer {
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (
|
||||
!params ||
|
||||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
|
||||
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
|
||||
) {
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
delete nextParams.subminerEnrich;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
@@ -496,7 +455,7 @@ export class AnkiConnectProxyServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueNotes(noteIds: number[], recordAdded = true): void {
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
@@ -513,7 +472,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
@@ -111,27 +110,6 @@ function createWorkflowHarness() {
|
||||
};
|
||||
}
|
||||
|
||||
test('NoteUpdateWorkflow preserves stats cards discovered by polling', async () => {
|
||||
const { workflow, deps, updates } = createWorkflowHarness();
|
||||
const note = {
|
||||
noteId: 42,
|
||||
tags: [STATS_MINING_TAG],
|
||||
fields: { Expression: { value: '猫' }, Sentence: { value: '猫がいる。' } },
|
||||
};
|
||||
deps.client.notesInfo = async () => [note];
|
||||
deps.captureSubtitleMediaContext = () => assert.fail('Must not capture current playback');
|
||||
deps.findDuplicateNote = async () => assert.fail('Must not regroup a stats card');
|
||||
let cachedNote: NoteUpdateWorkflowNoteInfo | undefined;
|
||||
deps.appendKnownWordsFromNoteInfo = (value) => {
|
||||
cachedNote = value;
|
||||
};
|
||||
|
||||
await workflow.execute(42);
|
||||
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(cachedNote, note);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow updates sentence field and emits notification', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@ import type {
|
||||
WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import { STATS_MINING_TAG } from '../shared/anki-source';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
tags?: string[];
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
@@ -183,10 +181,6 @@ export class NoteUpdateWorkflow {
|
||||
}
|
||||
|
||||
const noteInfo = notesInfo[0]!;
|
||||
if (noteInfo.tags?.includes(STATS_MINING_TAG)) {
|
||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||
return;
|
||||
}
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const config = this.deps.getConfig();
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -476,21 +475,3 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
|
||||
const args = parseArgs(['--hachidori']);
|
||||
assert.equal(args.hachidori, true);
|
||||
assert.equal(args.yomitan, false);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
|
||||
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
|
||||
assert.equal(
|
||||
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
|
||||
});
|
||||
|
||||
+1
-11
@@ -13,7 +13,6 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
@@ -135,7 +134,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -287,7 +285,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--hachidori') args.hachidori = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
@@ -571,7 +568,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -651,7 +647,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
@@ -724,7 +719,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -775,12 +769,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
}
|
||||
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
|
||||
}
|
||||
|
||||
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
(args.yomitan || args.hachidori) &&
|
||||
args.yomitan &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
|
||||
@@ -24,7 +24,6 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
|
||||
@@ -25,7 +25,6 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--yomitan Open Yomitan settings window
|
||||
--hachidori Open Hachidori settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -22,8 +22,6 @@ export type {
|
||||
} from './definitions/shared';
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -58,9 +56,8 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'hachidori'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -20,8 +18,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
hachidori: { externalHostManagementUrl: '' },
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
@@ -103,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSubtitleSelection: 'g-s',
|
||||
openSubtitleGeneration: 'Ctrl+Shift+G',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
|
||||
@@ -81,20 +81,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'hachidori.externalHostManagementUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.hachidori.externalHostManagementUrl,
|
||||
description:
|
||||
'Docker host management URL for automatic character dictionary uploads and replacement. Empty disables external uploads.',
|
||||
},
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
enumValues: ['yomitan', 'hachidori'],
|
||||
defaultValue: defaultConfig.dictionaryBackend,
|
||||
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
|
||||
},
|
||||
{
|
||||
path: 'logging.level',
|
||||
kind: 'enum',
|
||||
@@ -642,6 +628,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleSelection',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
|
||||
description:
|
||||
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleGeneration',
|
||||
kind: 'string',
|
||||
|
||||
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
return [
|
||||
{
|
||||
path: 'subtitleSelection.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleSelection.enabled,
|
||||
description:
|
||||
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
|
||||
},
|
||||
...(
|
||||
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
|
||||
).map((key) => ({
|
||||
|
||||
@@ -2,20 +2,10 @@ import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Dictionary Backend',
|
||||
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
|
||||
notes: [
|
||||
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Hachidori External Dictionary Imports',
|
||||
description: [
|
||||
'Configure the linked Docker host management URL, for example http://127.0.0.1:8780.',
|
||||
],
|
||||
notes: ['Used only while Hachidori is linked to an external host.'],
|
||||
key: 'hachidori',
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
|
||||
key: 'subtitleSelection',
|
||||
},
|
||||
{
|
||||
title: 'Japanese Subtitle Generation',
|
||||
|
||||
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
|
||||
const HOT_RELOAD_ROOTS = [
|
||||
'subtitleStyle',
|
||||
'keybindings',
|
||||
'shortcuts',
|
||||
'subtitleSidebar',
|
||||
'subtitleSelection',
|
||||
] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
|
||||
@@ -2,34 +2,26 @@ import { ResolveContext } from './context';
|
||||
import { applyControllerConfig } from './controller';
|
||||
import { isNotificationType, isOverlayNotificationPosition } from '../../types/notification';
|
||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
import { parseHachidoriManagementUrl } from '../../shared/hachidori-sharing';
|
||||
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.hachidori) && src.hachidori.externalHostManagementUrl !== undefined) {
|
||||
try {
|
||||
resolved.hachidori.externalHostManagementUrl = parseHachidoriManagementUrl(
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
);
|
||||
} catch {
|
||||
if (isObject(src.subtitleSelection)) {
|
||||
const enabled = asBoolean(src.subtitleSelection.enabled);
|
||||
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
|
||||
else if (src.subtitleSelection.enabled !== undefined)
|
||||
warn(
|
||||
'hachidori.externalHostManagementUrl',
|
||||
src.hachidori.externalHostManagementUrl,
|
||||
resolved.hachidori.externalHostManagementUrl,
|
||||
'Expected an HTTP(S) origin or an empty string.',
|
||||
'subtitleSelection.enabled',
|
||||
src.subtitleSelection.enabled,
|
||||
resolved.subtitleSelection.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
|
||||
resolved.dictionaryBackend = src.dictionaryBackend;
|
||||
} else if (src.dictionaryBackend !== undefined) {
|
||||
} else if (src.subtitleSelection !== undefined) {
|
||||
warn(
|
||||
'dictionaryBackend',
|
||||
src.dictionaryBackend,
|
||||
resolved.dictionaryBackend,
|
||||
"Expected 'yomitan' or 'hachidori'.",
|
||||
'subtitleSelection',
|
||||
src.subtitleSelection,
|
||||
resolved.subtitleSelection,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,6 +256,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { buildConfigSettingsRegistry } from '../settings/registry';
|
||||
import { createResolveContext } from './context';
|
||||
import { applyCoreDomainConfig } from './core-domains';
|
||||
|
||||
test('dictionary backend defaults to Yomitan and accepts Hachidori', () => {
|
||||
assert.equal(resolveConfig({}).resolved.dictionaryBackend, 'yomitan');
|
||||
const { resolved, warnings } = resolveConfig({ dictionaryBackend: 'hachidori' });
|
||||
assert.equal(resolved.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(warnings, []);
|
||||
const field = buildConfigSettingsRegistry(resolved).find(
|
||||
(entry) => entry.configPath === 'dictionaryBackend',
|
||||
);
|
||||
assert.equal(field?.restartBehavior, 'restart');
|
||||
assert.deepEqual(field?.enumValues, ['yomitan', 'hachidori']);
|
||||
assert.equal(field?.category, 'integrations');
|
||||
});
|
||||
|
||||
test('unknown dictionary backend values warn and preserve the default', () => {
|
||||
for (const dictionaryBackend of ['unknown', '', null, true, {}]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.dictionaryBackend = dictionaryBackend;
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.dictionaryBackend, 'yomitan');
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(warnings[0]?.path, 'dictionaryBackend');
|
||||
}
|
||||
});
|
||||
|
||||
test('Hachidori external import URL accepts HTTP origins and rejects invalid targets', () => {
|
||||
assert.equal(resolveConfig({}).resolved.hachidori.externalHostManagementUrl, '');
|
||||
const result = resolveConfig({
|
||||
hachidori: { externalHostManagementUrl: 'http://127.0.0.1:8780/' },
|
||||
});
|
||||
assert.equal(result.resolved.hachidori.externalHostManagementUrl, 'http://127.0.0.1:8780');
|
||||
assert.deepEqual(result.warnings, []);
|
||||
for (const value of [
|
||||
'file:///tmp/dict',
|
||||
'http://host/import',
|
||||
'http://user:password@host',
|
||||
true,
|
||||
]) {
|
||||
const { context, warnings } = createResolveContext({});
|
||||
context.src.hachidori = { externalHostManagementUrl: value };
|
||||
applyCoreDomainConfig(context);
|
||||
assert.equal(context.resolved.hachidori.externalHostManagementUrl, '');
|
||||
assert.equal(warnings[0]?.path, 'hachidori.externalHostManagementUrl');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import { buildConfigSettingsRegistry } from '../settings/registry';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import {
|
||||
compileSessionBindings,
|
||||
buildPluginSessionBindingsArtifact,
|
||||
} from '../../core/services/session-bindings';
|
||||
|
||||
function bindings(config: ReturnType<typeof resolveConfig>['resolved']) {
|
||||
return compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
keybindings: config.keybindings,
|
||||
platform: 'linux',
|
||||
});
|
||||
}
|
||||
|
||||
test('subtitle selection is opt-in and enabling it compiles g-s for mpv and the overlay', () => {
|
||||
const defaults = resolveConfig({}).resolved;
|
||||
assert.equal(defaults.subtitleSelection.enabled, false);
|
||||
assert.equal(defaults.shortcuts.openSubtitleSelection, 'g-s');
|
||||
const find = (config: typeof defaults) =>
|
||||
bindings(config).bindings.find(
|
||||
(binding) =>
|
||||
binding.actionType === 'session-action' && binding.actionId === 'openSubtitleSelection',
|
||||
);
|
||||
assert.equal(find(defaults), undefined);
|
||||
const enabled = resolveConfig({ subtitleSelection: { enabled: true } }).resolved;
|
||||
const binding = find(enabled);
|
||||
assert.ok(binding);
|
||||
assert.deepEqual(binding.key, { code: 'KeyG-KeyS', modifiers: [] });
|
||||
assert.equal(bindings(enabled).warnings.length, 0);
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings: [binding],
|
||||
warnings: [],
|
||||
numericSelectionTimeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(artifact.bindings[0], {
|
||||
...binding,
|
||||
cliArgs: ['--session-action', '{"actionId":"openSubtitleSelection"}'],
|
||||
});
|
||||
enabled.subtitleSelection.enabled = false;
|
||||
assert.equal(find(enabled), undefined);
|
||||
});
|
||||
|
||||
test('subtitle selection settings are validated, hot reloadable, and the shortcut can be cleared', () => {
|
||||
// @ts-expect-error Config files can contain invalid values at runtime.
|
||||
const { resolved, warnings } = resolveConfig({ subtitleSelection: { enabled: 'yes' } });
|
||||
assert.equal(resolved.subtitleSelection.enabled, false);
|
||||
assert.equal(warnings.length, 1);
|
||||
const field = buildConfigSettingsRegistry(resolved).find(
|
||||
(entry) => entry.configPath === 'subtitleSelection.enabled',
|
||||
);
|
||||
assert.equal(field?.category, 'behavior');
|
||||
assert.equal(field?.restartBehavior, 'hot-reload');
|
||||
const cleared = resolveConfig({
|
||||
subtitleSelection: { enabled: true },
|
||||
shortcuts: { openSubtitleSelection: null },
|
||||
}).resolved;
|
||||
assert.equal(resolveConfiguredShortcuts(cleared, DEFAULT_CONFIG).openSubtitleSelection, null);
|
||||
});
|
||||
@@ -152,7 +152,6 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Dictionary Lookup',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
@@ -235,8 +234,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
@@ -277,7 +276,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup':
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
@@ -341,9 +340,6 @@ function humanizePath(path: string): string {
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (path === 'dictionaryBackend' || path.startsWith('hachidori.')) {
|
||||
return { category: 'integrations', section: 'Dictionary Lookup' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
@@ -459,6 +455,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('subsync.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subtitleSelection.')) {
|
||||
return { category: 'behavior', section: 'Subtitle Selection' };
|
||||
}
|
||||
if (path.startsWith('subtitleGeneration.')) {
|
||||
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
|
||||
}
|
||||
@@ -635,6 +634,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSubtitleSelection' ||
|
||||
leaf === 'openSubtitleGeneration' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
|
||||
@@ -2525,8 +2525,6 @@ Aligned English subtitle
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
|
||||
const addRequest = requests.find((request) => request.action === 'addNote');
|
||||
assert.deepEqual(addRequest?.params?.note?.tags, ['SubMiner', 'SubMiner::Stats']);
|
||||
mediaRelease.audio?.();
|
||||
mediaRelease.image?.();
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -20,7 +20,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -134,9 +133,6 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -677,7 +673,6 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
@@ -1138,14 +1133,3 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
|
||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
for (const source of ['initial', 'second-instance'] as const) {
|
||||
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
|
||||
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
assert.equal(calls.includes('initializeOverlayRuntime'), false);
|
||||
assert.equal(calls.includes('connectMpvClient'), false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openHachidoriSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
@@ -171,7 +170,6 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -277,11 +275,6 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openHachidoriSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
@@ -432,8 +425,6 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.yomitan) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.hachidori) {
|
||||
deps.openHachidoriSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveHachidoriExtensionPath } from './hachidori-extension';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
|
||||
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
|
||||
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
||||
}),
|
||||
'/app/build/hachidori',
|
||||
);
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/resources/hachidori/manifest.json',
|
||||
}),
|
||||
'/resources/hachidori',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
|
||||
/build:hachidori/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
|
||||
try {
|
||||
const source = path.join(root, 'source');
|
||||
const profile = path.join(root, 'profile');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
|
||||
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
|
||||
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
|
||||
typeof ensureExtensionCopyAsync
|
||||
>[2];
|
||||
const copy = await ensureExtensionCopyAsync(source, profile, options);
|
||||
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
|
||||
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import electron from 'electron';
|
||||
import type { Extension, Session } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
|
||||
|
||||
export function getHachidoriSession(): Session {
|
||||
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
|
||||
}
|
||||
|
||||
export function resolveHachidoriExtensionPath(options: {
|
||||
moduleDir: string;
|
||||
resourcesPath: string;
|
||||
exists?: (candidate: string) => boolean;
|
||||
}): string {
|
||||
const candidates = [
|
||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
||||
path.join(options.resourcesPath, 'hachidori'),
|
||||
];
|
||||
const found = candidates.find((candidate) =>
|
||||
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
|
||||
);
|
||||
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
|
||||
export function createHachidoriExtensionRuntime(userDataPath: string) {
|
||||
let extension: Extension | null = null;
|
||||
let loading: Promise<Extension> | null = null;
|
||||
return {
|
||||
getSession: getHachidoriSession,
|
||||
ensureLoaded(): Promise<Extension> {
|
||||
if (extension) return Promise.resolve(extension);
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
const source = resolveHachidoriExtensionPath({
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
|
||||
extensionName: 'hachidori',
|
||||
});
|
||||
const session = getHachidoriSession();
|
||||
// Electron can reuse an old extension worker after its files change.
|
||||
// Drop worker registrations before loading, preserving dictionaries and settings.
|
||||
await session.clearStorageData({ storages: ['serviceworkers'] });
|
||||
extension = await session.extensions.loadExtension(copy.targetDir, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
return extension;
|
||||
})().finally(() => {
|
||||
loading = null;
|
||||
});
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openSubtitleSelection: () => calls.push('subtitle-selection'),
|
||||
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openSubtitleSelection: () => void;
|
||||
openSubtitleGeneration: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openSubtitleSelection':
|
||||
deps.openSubtitleSelection();
|
||||
return;
|
||||
case 'openSubtitleGeneration':
|
||||
deps.openSubtitleGeneration();
|
||||
return;
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
@@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co
|
||||
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
|
||||
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
|
||||
});
|
||||
|
||||
test('single keys reserve sequence prefixes without reserving the second stroke', () => {
|
||||
for (const key of ['g', 'Ctrl+g', 's']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }),
|
||||
keybindings: [createKeybinding(key, ['show-text', 'single'])],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === key));
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'g-s'),
|
||||
key !== 'g',
|
||||
);
|
||||
assert.equal(result.warnings.length, key === 'g' ? 1 : 0);
|
||||
if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => {
|
||||
for (const prefix of ['g', 'y', 'v']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === `${prefix}-s`),
|
||||
false,
|
||||
);
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === 'g'));
|
||||
assert.equal(result.warnings.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('sequence reservations follow the sidebar code and literal Shift semantics', () => {
|
||||
for (const [toggleKey, disabled] of [
|
||||
['KeyG', true],
|
||||
['g', false],
|
||||
['G', true],
|
||||
] as const) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
rawConfig: {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'),
|
||||
!disabled,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
SessionKeySpec,
|
||||
} from '../../types/session-bindings';
|
||||
import { SPECIAL_COMMANDS } from '../../config';
|
||||
import {
|
||||
resolveSessionSequenceConflicts,
|
||||
type SessionKeyReservation,
|
||||
} from '../../shared/session-key-sequences';
|
||||
|
||||
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
||||
|
||||
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
|
||||
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
@@ -81,6 +86,13 @@ function normalizeCodeToken(
|
||||
): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
// Two lowercase letters use mpv's sequential-key syntax, for example g-s.
|
||||
if (/^[a-z]-[a-z]$/.test(normalized)) {
|
||||
return normalized
|
||||
.split('-')
|
||||
.map((letter) => `Key${letter.toUpperCase()}`)
|
||||
.join('-');
|
||||
}
|
||||
if (options.allowMouseButtons === true) {
|
||||
const normalizedMouse = normalized.toUpperCase();
|
||||
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
|
||||
@@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
|
||||
}
|
||||
|
||||
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
||||
return { bindings, warnings };
|
||||
const reservations: SessionKeyReservation[] = [
|
||||
{ key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' },
|
||||
{ key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' },
|
||||
...[...candidates.values()].flatMap((drafts) =>
|
||||
drafts.map(({ binding }) => ({
|
||||
key: binding.key,
|
||||
path: binding.sourcePath,
|
||||
})),
|
||||
),
|
||||
];
|
||||
const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey;
|
||||
if (sidebarKey) {
|
||||
const { key } = parseSessionBindingKey(sidebarKey, input.platform);
|
||||
if (key) {
|
||||
// The sidebar accepts DOM codes with either Shift state, or literal characters.
|
||||
if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift'];
|
||||
reservations.push({ key, path: 'subtitleSidebar.toggleKey' });
|
||||
if (/^Key[A-Z]$/.test(sidebarKey)) {
|
||||
reservations.push({
|
||||
key: { ...key, modifiers: ['shift'] },
|
||||
path: 'subtitleSidebar.toggleKey',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = resolveSessionSequenceConflicts(bindings, reservations);
|
||||
return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] };
|
||||
}
|
||||
|
||||
export function buildPluginSessionBindingsArtifact(input: {
|
||||
|
||||
@@ -15,7 +15,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
|
||||
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
|
||||
import { MediaGenerator } from '../../../media-generator.js';
|
||||
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||
import { STATS_MINING_TAG } from '../../../shared/anki-source.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
@@ -360,7 +359,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
||||
}
|
||||
|
||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||
const tags = [...new Set([...(ankiConfig.tags ?? ['SubMiner']), STATS_MINING_TAG])];
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
|
||||
@@ -4362,26 +4362,6 @@ test('tokenizeSubtitle keeps Yomitan frequency for noun-particle-noun compounds'
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 581);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle skips frequency requests for ranks supplied by the scanner', async () => {
|
||||
const deps = makeDepsFromYomitanTokens(
|
||||
[{ surface: '猫', reading: 'ねこ', headword: '猫', frequencyRank: 42 }],
|
||||
{ getFrequencyDictionaryEnabled: () => true },
|
||||
);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
assert.ok(parserWindow);
|
||||
deps.getYomitanParserWindow = () => parserWindow;
|
||||
const scripts: string[] = [];
|
||||
const execute = parserWindow.webContents.executeJavaScript.bind(parserWindow.webContents);
|
||||
parserWindow.webContents.executeJavaScript = async (script) => {
|
||||
scripts.push(script);
|
||||
return execute(script);
|
||||
};
|
||||
const result = await tokenizeSubtitle('猫', deps);
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 42);
|
||||
assert.ok(scripts.length > 0);
|
||||
assert.equal(scripts.filter((script) => script.includes('getTermFrequencies')).length, 0);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle keeps frequency for ordinal prefix-noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'第二走者',
|
||||
|
||||
@@ -494,7 +494,6 @@ function buildYomitanFrequencyTermReadingList(
|
||||
): Array<{ term: string; reading: string | null }> {
|
||||
const termReadingList: Array<{ term: string; reading: string | null }> = [];
|
||||
for (const token of tokens) {
|
||||
if (normalizePositiveFrequencyRank(token.frequencyRank) !== null) continue;
|
||||
const readingRaw =
|
||||
token.reading && token.reading.trim().length > 0 ? token.reading.trim() : null;
|
||||
for (const term of resolveYomitanFrequencyLookupTexts(token, matchMode)) {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import { buildHachidoriAnkiHints, HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
|
||||
|
||||
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
|
||||
const config = {
|
||||
tags: ['SubMiner', 'Japanese'],
|
||||
fields: { word: 'Term', sentence: 'Context', wordAudio: 'Pronunciation', image: 'Image' },
|
||||
};
|
||||
const modelFields = ['Term', 'Reading', 'Definition', 'Context', 'Pronunciation', 'Image'];
|
||||
|
||||
async function harness(
|
||||
anki: unknown = {},
|
||||
models: Record<string, string[]> = { Japanese: modelFields },
|
||||
) {
|
||||
const templates: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-templates.js')).href
|
||||
);
|
||||
const setup: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-setup.js')).href
|
||||
);
|
||||
const context = vm.createContext({
|
||||
__templates: templates,
|
||||
__setup: setup,
|
||||
__initialAnki: anki,
|
||||
__models: models,
|
||||
structuredClone,
|
||||
URL,
|
||||
});
|
||||
vm.runInContext('globalThis.window = globalThis', context);
|
||||
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
|
||||
vm.runInContext(
|
||||
`
|
||||
let options = { ...HDReaderOptions.normaliseOptions({ anki: __initialAnki }), revision: 1 };
|
||||
let writes = 0, online = true, race = false, proxy = null;
|
||||
const readOptions = async () => structuredClone(options);
|
||||
globalThis.__subminerSetAnkiProxyUrl = async value => { const old = proxy; proxy = value; return old; };
|
||||
const send = async (type, request, target) => {
|
||||
if (type !== 'hd_options_write' || target !== 'hoshidicts-worker') throw Error('Unexpected request');
|
||||
if (race) {
|
||||
race = false;
|
||||
options.anki.templates[0].deck = options.anki.deck = 'User edit';
|
||||
options.revision++;
|
||||
}
|
||||
if (request.baseRevision !== options.revision) throw Error('conflict');
|
||||
options = { ...HDReaderOptions.normaliseOptions({ ...options, ...request.options }), revision: options.revision + 1 };
|
||||
writes++;
|
||||
};
|
||||
const __gateway = { createAnkiGateway: () => ({ discover: async ({ model }) => ({
|
||||
connected: online, model, models: Object.keys(__models), decks: ['Mining'],
|
||||
fields: __models[model] || [], errors: online ? [] : ['offline'],
|
||||
}) }) };
|
||||
`,
|
||||
context,
|
||||
);
|
||||
await vm.runInContext(
|
||||
HACHIDORI_ANKI_SETTINGS_SCRIPT.replace("await import('./anki-templates.js')", '__templates')
|
||||
.replace("await import('./anki-setup.js')", '__setup')
|
||||
.replace("await import('./anki.js')", '__gateway'),
|
||||
context,
|
||||
);
|
||||
const run = async (script: string): Promise<unknown> =>
|
||||
structuredClone(await vm.runInContext(script, context));
|
||||
const sync = () =>
|
||||
run(
|
||||
`__subminerSyncAnkiSettings(${JSON.stringify({
|
||||
server: 'http://127.0.0.1:8766',
|
||||
deck: 'Mining',
|
||||
forceOverride: true,
|
||||
hints: buildHachidoriAnkiHints(config),
|
||||
})})`,
|
||||
);
|
||||
return { run, sync };
|
||||
}
|
||||
|
||||
test('fresh Hachidori settings inherit deck, tags, a unique model and configured fields', async () => {
|
||||
const h = await harness();
|
||||
assert.deepEqual(await h.sync(), { updated: true, matched: true, pending: false });
|
||||
assert.deepEqual(
|
||||
await h.run('[options.anki.url, options.anki.deck, options.anki.model, options.anki.tags]'),
|
||||
['http://127.0.0.1:8766', 'Mining', 'Japanese', config.tags],
|
||||
);
|
||||
assert.deepEqual(
|
||||
await h.run(
|
||||
'Object.fromEntries(Object.entries(options.anki.fieldTemplates).map(([key, row]) => [key, row.value]))',
|
||||
),
|
||||
{
|
||||
Term: '{expression}',
|
||||
Reading: '{reading}',
|
||||
Definition: '{definition}',
|
||||
Context: '{sentence}',
|
||||
Pronunciation: '{audio}',
|
||||
Image: '{screenshot}',
|
||||
},
|
||||
);
|
||||
await h.sync();
|
||||
assert.equal(await h.run('writes'), 1);
|
||||
});
|
||||
|
||||
test('preserves custom templates, tags, intentional blank fields and additional templates', async () => {
|
||||
const h = await harness({
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Custom',
|
||||
deck: 'Own deck',
|
||||
model: 'Japanese',
|
||||
tags: ['own'],
|
||||
fieldTemplates: {
|
||||
Term: { value: '{reading}', overwriteMode: 'overwrite' },
|
||||
Context: { value: '', overwriteMode: 'coalesce' },
|
||||
},
|
||||
},
|
||||
{ id: 'second', name: 'Second', deck: 'Other', model: 'Other', tags: [] },
|
||||
],
|
||||
});
|
||||
const before = await h.run('options.anki.templates');
|
||||
await h.sync();
|
||||
assert.deepEqual(await h.run('options.anki.templates'), before);
|
||||
});
|
||||
|
||||
test('leaves an ambiguous model unset and retries discovery after Anki reconnects', async () => {
|
||||
const h = await harness({}, { Japanese: modelFields, Second: modelFields });
|
||||
await h.run('online = false');
|
||||
assert.deepEqual(await h.sync(), { updated: true, matched: false, pending: true });
|
||||
assert.equal(await h.run('options.anki.deck'), 'Mining');
|
||||
await h.run('online = true');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.model'), '');
|
||||
await h.run('delete __models.Second');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.model'), 'Japanese');
|
||||
});
|
||||
|
||||
test('fills missing basic mappings only with fields belonging to the selected model', async () => {
|
||||
const h = await harness(
|
||||
{ model: 'Japanese', fields: { expression: 'Reading' } },
|
||||
{ Japanese: ['Term', 'Reading', 'Context'] },
|
||||
);
|
||||
await h.sync();
|
||||
assert.deepEqual(
|
||||
await h.run(
|
||||
'[options.anki.fields.expression, options.anki.fields.sentence, options.anki.fields.audio, options.anki.fieldTemplates]',
|
||||
),
|
||||
['Reading', 'Context', '', null],
|
||||
);
|
||||
});
|
||||
|
||||
test('re-reads concurrent settings edits before retrying its revisioned write', async () => {
|
||||
const h = await harness();
|
||||
await h.run('race = true');
|
||||
await h.sync();
|
||||
assert.equal(await h.run('options.anki.deck'), 'User edit');
|
||||
assert.equal(await h.run('options.anki.model'), 'Japanese');
|
||||
});
|
||||
|
||||
test('keeps sentence audio out of captured-audio settings and uses wordAudio first', () => {
|
||||
const hints = buildHachidoriAnkiHints({
|
||||
fields: { audio: 'SentenceAudio', wordAudio: 'WordAudio' },
|
||||
});
|
||||
assert.equal(hints.fields.audio, 'WordAudio');
|
||||
assert.equal('captureAudio' in hints.fields, false);
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
|
||||
// Only settings with the same meaning in both apps cross this boundary.
|
||||
export function buildHachidoriAnkiHints(config: AnkiConnectConfig) {
|
||||
return {
|
||||
tags: config.tags,
|
||||
fields: {
|
||||
expression: config.fields?.word?.trim(),
|
||||
audio: (config.fields?.wordAudio || config.fields?.audio)?.trim(),
|
||||
sentence: config.fields?.sentence?.trim(),
|
||||
screenshot: config.fields?.image?.trim(),
|
||||
},
|
||||
model: config.isLapis?.enabled ? config.isLapis.sentenceCardModel?.trim() : undefined,
|
||||
family: config.isLapis?.enabled
|
||||
? 'lapis'
|
||||
: config.isKiku?.enabled
|
||||
? 'kiku'
|
||||
: config.isSenren?.enabled
|
||||
? 'senren'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Installed inside the hidden Hachidori settings page. Uses local option writes
|
||||
// even when dictionaries are remote or their host is offline.
|
||||
export const HACHIDORI_ANKI_SETTINGS_SCRIPT = String.raw`
|
||||
globalThis.__subminerSyncAnkiSettings = async ({ server, deck, forceOverride, hints }) => {
|
||||
const previousProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? server : null);
|
||||
const { applyAnkiPreset, resolveAnkiTemplates } = await import('./anki-templates.js');
|
||||
const { ankiSetupFamily } = await import('./anki-setup.js');
|
||||
const { createAnkiGateway } = await import('./anki.js');
|
||||
const gateway = createAnkiGateway({ timeoutMs: 2000 });
|
||||
const discoveries = new Map();
|
||||
const discover = async (anki, model) => {
|
||||
const key = JSON.stringify([anki.url, anki.apiKey, model]);
|
||||
if (!discoveries.has(key)) discoveries.set(key, gateway.discover({ ...anki, model }));
|
||||
return discoveries.get(key);
|
||||
};
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const options = await readOptions();
|
||||
let anki = options.anki;
|
||||
const canReplaceServer = forceOverride || !anki.url || anki.url === server
|
||||
|| anki.url === 'http://127.0.0.1:8765' || anki.url === previousProxy;
|
||||
if (!canReplaceServer) return { updated: false, matched: false, reason: 'blocked-existing-server' };
|
||||
anki = { ...anki, url: server };
|
||||
const first = anki.templates[0];
|
||||
if (!first) return { updated: false, matched: false, reason: 'no-templates' };
|
||||
let template = { ...first, fields: { ...first.fields } };
|
||||
const pristine = !first.model && first.fieldTemplates === null
|
||||
&& Object.values(first.fields).every(value => !value);
|
||||
if (deck && (!first.deck || (pristine && first.deck === 'Default'))) template.deck = deck;
|
||||
if (hints?.tags && JSON.stringify(first.tags) === JSON.stringify(['hachidori'])) {
|
||||
template.tags = [...hints.tags];
|
||||
}
|
||||
let pending = false;
|
||||
// Advanced templates include intentionally blank fields. Preserve them
|
||||
// as a unit rather than replacing them with inferred mappings.
|
||||
if (hints && first.fieldTemplates === null) {
|
||||
let discovery = await discover(anki, template.model || hints.model || '');
|
||||
if (!discovery.connected) {
|
||||
pending = true;
|
||||
} else {
|
||||
if (!template.model) {
|
||||
const candidates = hints.model
|
||||
? discovery.models.filter(model => model === hints.model)
|
||||
: discovery.models.filter(model => !hints.family || ankiSetupFamily(model) === hints.family);
|
||||
const anchors = [hints.fields.expression, hints.fields.sentence].filter(Boolean);
|
||||
const matches = [];
|
||||
// Require a configured word and sentence field for an inferred
|
||||
// model. Never choose the first of several compatible note types.
|
||||
if (anchors.length === 2) for (const model of candidates) {
|
||||
const result = await discover(anki, model);
|
||||
if (!result.connected || result.errors.length) { pending = true; break; }
|
||||
if (anchors.every(field => result.fields.includes(field))) matches.push(result);
|
||||
}
|
||||
if (!pending && matches.length === 1) {
|
||||
discovery = matches[0];
|
||||
template.model = discovery.model;
|
||||
}
|
||||
}
|
||||
if (template.model && discovery.model === template.model && discovery.fields.length) {
|
||||
const fields = discovery.fields;
|
||||
for (const [semantic, field] of Object.entries(hints.fields)) {
|
||||
if (!template.fields[semantic] && field && fields.includes(field)) template.fields[semantic] = field;
|
||||
}
|
||||
if (Object.values(first.fields).every(value => !value)) {
|
||||
const preset = applyAnkiPreset(template, fields, ankiSetupFamily(template.model) || 'automatic');
|
||||
const configured = resolveAnkiTemplates(template, fields).templates;
|
||||
const markers = new Set(Object.entries(template.fields)
|
||||
.filter(([, value]) => value).map(([key]) => '{' + key + '}'));
|
||||
for (const row of Object.values(preset.fieldTemplates)) {
|
||||
for (const marker of markers) row.value = row.value.replaceAll(marker, '');
|
||||
}
|
||||
for (const [field, row] of Object.entries(configured)) {
|
||||
if (row.value) preset.fieldTemplates[field] = row;
|
||||
}
|
||||
template = preset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hachidori retains a compatibility projection of its first template.
|
||||
// Updating both prevents its normalizer from restoring stale values.
|
||||
const templateConfig = Object.fromEntries(globalThis.HDReaderOptions.ANKI_TEMPLATE_CONFIG_KEYS
|
||||
.map(key => [key, template[key]]));
|
||||
anki = { ...anki, ...templateConfig,
|
||||
templates: [template, ...anki.templates.slice(1)] };
|
||||
const changed = JSON.stringify(anki) !== JSON.stringify(options.anki);
|
||||
try {
|
||||
if (changed) await send('hd_options_write', {
|
||||
baseRevision: options.revision, options: { anki },
|
||||
}, 'hoshidicts-worker');
|
||||
return { updated: changed, matched: !pending, pending };
|
||||
} catch (error) {
|
||||
if (attempt > 0) throw error;
|
||||
// Re-read after a concurrent settings save before filling anything.
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
@@ -1,100 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
|
||||
import { importYomitanDictionaryFromZip } from './yomitan-parser-runtime';
|
||||
import { createDeps } from './yomitan-scan-test-harness';
|
||||
|
||||
test('linked Hachidori uploads replacement bytes, retries a busy host, and never invokes local import', async () => {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
const archive = Buffer.from('PK-test-archive');
|
||||
await writeFile(zipPath, archive);
|
||||
let attempts = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
attempts += 1;
|
||||
assert.equal(request.url, '/import?name=merged.zip&replace=true');
|
||||
assert.equal(request.method, 'POST');
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
assert.deepEqual(Buffer.concat(chunks), archive);
|
||||
response.writeHead(attempts === 1 ? 409 : 200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify(attempts === 1 ? { error: 'busy' } : { ok: true, report: { success: true } }),
|
||||
);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const managementUrl = `http://127.0.0.1:${address.port}`;
|
||||
let connected = true;
|
||||
const deps = {
|
||||
...createDeps(async (script) => {
|
||||
assert.ok(script.includes('hd_sharing_status'), 'must not invoke local ZIP automation');
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected,
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
host: { dictionaryCount: 8 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
getYomitanExt: () => ({
|
||||
id: 'hachi',
|
||||
name: 'Hachidori',
|
||||
version: '1',
|
||||
path: '',
|
||||
url: '',
|
||||
manifest: {},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await importYomitanDictionaryFromZip(zipPath, deps, { error: assert.fail }, managementUrl),
|
||||
true,
|
||||
);
|
||||
assert.equal(attempts, 2);
|
||||
const errors: string[] = [];
|
||||
const logger = { error: (...args: unknown[]) => errors.push(args.join(' ')) };
|
||||
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger), false);
|
||||
assert.match(errors.pop() ?? '', /externalHostManagementUrl/);
|
||||
connected = false;
|
||||
assert.equal(await importYomitanDictionaryFromZip(zipPath, deps, logger, managementUrl), false);
|
||||
assert.equal(attempts, 2);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test('Hachidori upload requires a successful import report, not just HTTP success', async () => {
|
||||
const zipPath = path.join(await mkdtemp(path.join(os.tmpdir(), 'hachi-import-')), 'merged.zip');
|
||||
await writeFile(zipPath, 'bad archive');
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(
|
||||
JSON.stringify({ ok: true, report: { success: false, error: 'Invalid archive' } }),
|
||||
);
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
try {
|
||||
await assert.rejects(
|
||||
uploadHachidoriDictionary(zipPath, `http://127.0.0.1:${address.port}`),
|
||||
/Invalid archive/,
|
||||
);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { parseHachidoriManagementUrl } from '../../../shared/hachidori-sharing';
|
||||
|
||||
// Upload from the main process: extension blob URLs cannot cross the sharing link,
|
||||
// and the Docker management API deliberately rejects browser cross-origin writes.
|
||||
export async function uploadHachidoriDictionary(
|
||||
zipPath: string,
|
||||
managementUrl: string,
|
||||
): Promise<void> {
|
||||
const origin = parseHachidoriManagementUrl(managementUrl);
|
||||
if (!origin) {
|
||||
throw new Error(
|
||||
'Set hachidori.externalHostManagementUrl to the linked Docker host management URL to sync character dictionaries.',
|
||||
);
|
||||
}
|
||||
const url = new URL('/import', origin);
|
||||
url.searchParams.set('name', path.basename(zipPath));
|
||||
url.searchParams.set('replace', 'true');
|
||||
const bytes = await readFile(zipPath);
|
||||
const signal = AbortSignal.timeout(300_000);
|
||||
for (;;) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: bytes,
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (response.status === 409) {
|
||||
await response.body?.cancel();
|
||||
await delay(500, undefined, { signal });
|
||||
continue;
|
||||
}
|
||||
const result: unknown = await response.json();
|
||||
if (
|
||||
response.ok &&
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'ok' in result &&
|
||||
result.ok === true &&
|
||||
'report' in result &&
|
||||
typeof result.report === 'object' &&
|
||||
result.report !== null &&
|
||||
'success' in result.report &&
|
||||
result.report.success === true
|
||||
)
|
||||
return;
|
||||
const detail =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'error' in result &&
|
||||
typeof result.error === 'string'
|
||||
? result.error
|
||||
: JSON.stringify(result);
|
||||
throw new Error(`Hachidori dictionary upload failed (${response.status}): ${detail}`);
|
||||
}
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as vm from 'node:vm';
|
||||
import { HACHIDORI_PARSER_BRIDGE_SCRIPT } from './hachidori-parser-bridge';
|
||||
import {
|
||||
requestYomitanScanTokens,
|
||||
requestYomitanParseResults,
|
||||
requestYomitanTermFrequencies,
|
||||
syncYomitanDefaultAnkiServer,
|
||||
addYomitanNoteViaSearch,
|
||||
} from './yomitan-parser-runtime';
|
||||
import { createDeps } from './yomitan-scan-test-harness';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
|
||||
const extensionPath = path.resolve(__dirname, '../../../../vendor/hachidori/extension');
|
||||
const characterDictionary = 'SubMiner Character Dictionary (AniList 1)';
|
||||
|
||||
function lookupResult(matched: string, expression: string, reading: string, dictionary = 'JMdict') {
|
||||
return {
|
||||
matched,
|
||||
deinflected: expression,
|
||||
trace: [],
|
||||
term: {
|
||||
expression,
|
||||
reading,
|
||||
score: 0,
|
||||
rules: 'v1',
|
||||
glossaries: [{ dictionary, glossary: '["definition"]', termTags: '', definitionTags: '' }],
|
||||
pitches: [],
|
||||
frequencies: [{ dictionary: 'Frequency', frequencies: [{ value: 42, displayValue: '' }] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(emptyLibrary = false) {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
let dictionaryRevision = 2;
|
||||
let optionRevision = 3;
|
||||
let anki = {
|
||||
url: 'http://127.0.0.1:8765',
|
||||
templates: [
|
||||
{ id: 'default', name: 'Default', deck: 'Old', model: 'Japanese', fields: {} },
|
||||
{ id: 'second', name: 'Second', deck: 'Other', model: 'Japanese', fields: {} },
|
||||
],
|
||||
};
|
||||
let dictionaries = [
|
||||
{ id: 'terms', title: 'JMdict', displayName: 'Main dictionary', enabled: true, revision: '1' },
|
||||
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
|
||||
{
|
||||
id: 'frequency',
|
||||
title: 'Frequency',
|
||||
enabled: true,
|
||||
revision: '1',
|
||||
frequencyMode: 'rank-based',
|
||||
frequencyCount: 1,
|
||||
},
|
||||
];
|
||||
let duplicate = false;
|
||||
let proxyUrl: unknown;
|
||||
let loadingStatusReplies = 0;
|
||||
let busyEngineReplies = 0;
|
||||
const apiModule: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'api-host.js')).href
|
||||
);
|
||||
const context = vm.createContext({
|
||||
__testApiModule: apiModule,
|
||||
setTimeout,
|
||||
crypto,
|
||||
URL,
|
||||
Blob,
|
||||
Uint8Array,
|
||||
atob,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
set: async (value: Record<string, unknown>) => {
|
||||
proxyUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
get: async () => ({
|
||||
options: { anki, scanLength: 40, revision: optionRevision },
|
||||
subminerAnkiProxyUrl: proxyUrl,
|
||||
}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getManifest: () => ({ version: 'test' }),
|
||||
sendMessage: async (message: Record<string, unknown>) => {
|
||||
messages.push(structuredClone(message));
|
||||
if (message.type === 'hd_status') {
|
||||
const loading = loadingStatusReplies > 0;
|
||||
if (loading) loadingStatusReplies -= 1;
|
||||
return { ok: true, ready: !loading, loading };
|
||||
}
|
||||
if (busyEngineReplies > 0 && message.target !== 'hachidori-anki') {
|
||||
busyEngineReplies -= 1;
|
||||
return { ok: false, error: 'the dictionary engine is busy mutating' };
|
||||
}
|
||||
switch (message.type) {
|
||||
case 'hd_state_read':
|
||||
return {
|
||||
ok: true,
|
||||
state: emptyLibrary ? null : { revision: dictionaryRevision, dictionaries },
|
||||
};
|
||||
case 'hd_lookup': {
|
||||
const text = String(message.text);
|
||||
const candidates = [
|
||||
lookupResult('食べた', '食べる', 'たべる'),
|
||||
lookupResult('食べる', '食べる', 'たべる'),
|
||||
lookupResult('ミナト', 'ミナト', 'みなと', characterDictionary),
|
||||
];
|
||||
return {
|
||||
ok: true,
|
||||
generation: 1,
|
||||
results: candidates.filter((result) => text.startsWith(result.matched)),
|
||||
};
|
||||
}
|
||||
case 'hd_options_write': {
|
||||
if (message.baseRevision !== optionRevision) return { ok: false, error: 'conflict' };
|
||||
const update = message.options;
|
||||
assert.ok(update && typeof update === 'object' && 'anki' in update);
|
||||
const value = update.anki;
|
||||
assert.ok(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'url' in value &&
|
||||
typeof value.url === 'string',
|
||||
);
|
||||
assert.ok('templates' in value && Array.isArray(value.templates));
|
||||
anki = { url: value.url, templates: value.templates };
|
||||
optionRevision += 1;
|
||||
return { ok: true };
|
||||
}
|
||||
case 'hd_apply_state': {
|
||||
assert.equal(message.baseRevision, dictionaryRevision);
|
||||
assert.ok(Array.isArray(message.dictionaries));
|
||||
dictionaries = message.dictionaries;
|
||||
dictionaryRevision += 1;
|
||||
return { ok: true };
|
||||
}
|
||||
case 'hd_anki_status':
|
||||
return { ok: true, configKey: 'configuration' };
|
||||
case 'hd_anki_preflight':
|
||||
return {
|
||||
ok: true,
|
||||
canAdd: !duplicate,
|
||||
state: duplicate ? 'duplicate' : 'addable',
|
||||
noteIds: duplicate ? [15] : [],
|
||||
};
|
||||
case 'hd_anki_submit':
|
||||
return { ok: true, state: 'added', noteId: 19 };
|
||||
case 'hd_import':
|
||||
return { ok: true, report: { success: true } };
|
||||
case 'hd_remove':
|
||||
return { ok: true };
|
||||
default:
|
||||
throw new Error('Unexpected native request: ' + String(message.type));
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vm.runInContext('globalThis.window = globalThis', context);
|
||||
vm.runInContext(await readFile(path.join(extensionPath, 'reader-options.js'), 'utf8'), context);
|
||||
const script = HACHIDORI_PARSER_BRIDGE_SCRIPT.replace(
|
||||
"await import('./api-host.js')",
|
||||
'__testApiModule',
|
||||
).replace("await import('./reader-options.js')", 'Promise.resolve()');
|
||||
await vm.runInContext(script, context);
|
||||
const run = async (code: string): Promise<unknown> =>
|
||||
structuredClone(await vm.runInContext(code, context));
|
||||
const invoke = (action: string, params?: unknown) =>
|
||||
run(`new Promise((resolve, reject) => {
|
||||
__subminerDictionarySendMessage(${JSON.stringify({ action, params })}, response => {
|
||||
if (response.error) reject(new Error(response.error.message)); else resolve(response.result);
|
||||
});
|
||||
})`);
|
||||
return {
|
||||
deps: createDeps(run),
|
||||
run,
|
||||
invoke,
|
||||
messages,
|
||||
anki: () => anki,
|
||||
proxyUrl: () => proxyUrl,
|
||||
setAnkiServer: (url: string) => {
|
||||
anki = { ...anki, url };
|
||||
},
|
||||
setAnkiTemplates: (templates: typeof anki.templates) => {
|
||||
anki = { ...anki, templates };
|
||||
},
|
||||
dictionaries: () => dictionaries,
|
||||
disableDictionary: (id: string) => {
|
||||
dictionaries = dictionaries.map((entry) =>
|
||||
entry.id === id ? { ...entry, enabled: false } : entry,
|
||||
);
|
||||
dictionaryRevision += 1;
|
||||
},
|
||||
setDuplicate: () => {
|
||||
duplicate = true;
|
||||
},
|
||||
changeRevision: () => {
|
||||
optionRevision += 1;
|
||||
},
|
||||
setLoadingStatusReplies: (count: number) => {
|
||||
loadingStatusReplies = count;
|
||||
},
|
||||
setBusyEngineReplies: (count: number) => {
|
||||
busyEngineReplies = count;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Hachidori runs the shared scanner with inflected offsets, headwords, names and frequencies', async () => {
|
||||
const harness = await createHarness();
|
||||
const tokens = await requestYomitanScanTokens(
|
||||
'ミナト 食べた',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{
|
||||
includeNameMatchMetadata: true,
|
||||
currentCharacterDictionaryMediaId: 1,
|
||||
},
|
||||
);
|
||||
assert.ok(tokens);
|
||||
assert.equal(tokens[0]?.surface, 'ミナト');
|
||||
assert.equal(tokens[0]?.isNameMatch, true);
|
||||
assert.equal(tokens[1]?.surface, '食べた');
|
||||
assert.equal(tokens[1]?.headword, '食べる');
|
||||
assert.equal(tokens[1]?.startPos, 4);
|
||||
assert.equal(tokens[1]?.endPos, 7);
|
||||
assert.equal(tokens[1]?.frequencyRank, 42);
|
||||
assert.deepEqual(tokens[1]?.wordClasses, ['v1']);
|
||||
const frequencies = await requestYomitanTermFrequencies(
|
||||
[{ term: '食べる', reading: 'たべる' }],
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
);
|
||||
assert.equal(frequencies[0]?.frequency, 42);
|
||||
assert.equal(frequencies[0]?.dictionary, 'Frequency');
|
||||
});
|
||||
|
||||
test('Hachidori frequency lookups match API headwords, readings and requested dictionaries', async () => {
|
||||
const harness = await createHarness();
|
||||
const query = (term: string, reading: string | null, dictionaries = ['Frequency']) =>
|
||||
harness.invoke('getTermFrequencies', { termReadingList: [{ term, reading }], dictionaries });
|
||||
assert.deepEqual(await query('食べる', 'たべる'), [
|
||||
{
|
||||
term: '食べる',
|
||||
reading: 'たべる',
|
||||
hasReading: false,
|
||||
dictionary: 'Frequency',
|
||||
frequency: 42,
|
||||
displayValue: null,
|
||||
displayValueParsed: false,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(await query('食べる', 'べつのよみ'), []);
|
||||
assert.deepEqual(await query('食べる', null, ['Other frequency']), []);
|
||||
assert.deepEqual(await query('食べるだけ', null), []);
|
||||
assert.deepEqual(await query('頻度だけ', null), []);
|
||||
assert.deepEqual(await query('食べる', null), await query('食べる', 'たべる'));
|
||||
});
|
||||
|
||||
test('Hachidori syncs the Anki endpoint and every term template through revisioned writes', async () => {
|
||||
const harness = await createHarness();
|
||||
const synced = await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ deck: 'Mining' },
|
||||
);
|
||||
assert.equal(synced, true);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
assert.deepEqual(
|
||||
harness.anki().templates.map((template) => template.deck),
|
||||
['Mining', 'Mining'],
|
||||
);
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ forceOverride: true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.proxyUrl(), 'http://127.0.0.1:8766');
|
||||
await assert.rejects(
|
||||
harness.invoke('setAllSettings', {
|
||||
value: {
|
||||
hachidoriRevisions: { dictionaries: 99, options: 99 },
|
||||
profiles: [{ options: { dictionaries: [], anki: { server: '', cardFormats: [] } } }],
|
||||
},
|
||||
}),
|
||||
/settings changed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori applies only SubMiner changes when its settings moved on meanwhile', async () => {
|
||||
const harness = await createHarness();
|
||||
const projected = (await harness.invoke('optionsGetFull')) as {
|
||||
profiles: Array<{ options: { dictionaries: Array<{ name: string; enabled: boolean }> } }>;
|
||||
};
|
||||
const jmdict = projected.profiles[0]!.options.dictionaries.find(
|
||||
(entry) => entry.name === 'JMdict',
|
||||
);
|
||||
jmdict!.enabled = false;
|
||||
// Hachidori changed both revisions after SubMiner read them.
|
||||
harness.disableDictionary('names');
|
||||
harness.setAnkiServer('http://192.168.1.10:8765');
|
||||
harness.changeRevision();
|
||||
assert.equal(await harness.invoke('setAllSettings', { value: projected }), true);
|
||||
assert.deepEqual(
|
||||
harness.dictionaries().map((entry) => [entry.id, entry.enabled]),
|
||||
[
|
||||
['terms', false],
|
||||
['names', false],
|
||||
['frequency', true],
|
||||
],
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
|
||||
assert.equal(harness.messages.filter((message) => message.type === 'hd_options_write').length, 0);
|
||||
});
|
||||
|
||||
test('Hachidori settings writes survive an empty template list', async () => {
|
||||
const harness = await createHarness();
|
||||
harness.setAnkiTemplates([]);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{
|
||||
forceOverride: true,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
});
|
||||
|
||||
test('Hachidori initializes an empty library before the first dictionary import', async () => {
|
||||
const harness = await createHarness(true);
|
||||
assert.deepEqual(await harness.invoke('getDictionaryInfo'), []);
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
harness.deps,
|
||||
{ error: assert.fail },
|
||||
{ forceOverride: true },
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori restores direct AnkiConnect when disabling its managed proxy', async () => {
|
||||
const harness = await createHarness();
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8766');
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://127.0.0.1:8765');
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
});
|
||||
|
||||
test('Hachidori preserves a custom Anki endpoint when disabling its managed proxy', async () => {
|
||||
const harness = await createHarness();
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', harness.deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
harness.setAnkiServer('http://192.168.1.10:8765');
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', harness.deps, logger),
|
||||
false,
|
||||
);
|
||||
assert.equal(harness.anki().url, 'http://192.168.1.10:8765');
|
||||
assert.equal(harness.proxyUrl(), null);
|
||||
});
|
||||
|
||||
test('Hachidori fallback parsing retains token boundaries, inflected readings and headwords', async () => {
|
||||
const harness = await createHarness();
|
||||
const parsed = await requestYomitanParseResults('ミナト 食べた', harness.deps, {
|
||||
error: assert.fail,
|
||||
});
|
||||
const tokens = selectYomitanParseTokens(parsed, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
headword: token.headword,
|
||||
reading: token.reading,
|
||||
start: token.startPos,
|
||||
})),
|
||||
[
|
||||
{ surface: 'ミナト', headword: 'ミナト', reading: 'みなと', start: 0 },
|
||||
{ surface: '食べた', headword: '食べる', reading: 'たべた', start: 4 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori stats mining returns note IDs and prevents duplicate submissions', async () => {
|
||||
const harness = await createHarness();
|
||||
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
|
||||
noteId: 19,
|
||||
duplicateNoteIds: [],
|
||||
});
|
||||
harness.setDuplicate();
|
||||
assert.deepEqual(await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail }), {
|
||||
noteId: null,
|
||||
duplicateNoteIds: [15],
|
||||
});
|
||||
assert.equal(harness.messages.filter((message) => message.type === 'hd_anki_submit').length, 1);
|
||||
});
|
||||
|
||||
test('Hachidori mining requests render native dictionary aliases and frequency markers', async () => {
|
||||
const harness = await createHarness();
|
||||
await addYomitanNoteViaSearch('食べる', harness.deps, { error: assert.fail });
|
||||
const request = harness.messages.find((message) => message.type === 'hd_anki_preflight')?.request;
|
||||
assert.ok(request && typeof request === 'object');
|
||||
assert.ok('subminerEnrich' in request && request.subminerEnrich === false);
|
||||
const native: unknown = await import(
|
||||
pathToFileURL(path.join(extensionPath, 'anki-values.js')).href
|
||||
);
|
||||
assert.ok(native && typeof native === 'object' && 'buildAnkiFields' in native);
|
||||
assert.equal(typeof native.buildAnkiFields, 'function');
|
||||
if (typeof native.buildAnkiFields !== 'function') assert.fail('Native renderer is unavailable');
|
||||
const fields: unknown = await native.buildAnkiFields(
|
||||
request,
|
||||
{
|
||||
Dictionary: { value: '{dictionary-alias}' },
|
||||
Frequency: { value: '{single-frequency-frequency}' },
|
||||
Rank: { value: '{frequency-harmonic-rank}' },
|
||||
},
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(fields, {
|
||||
Dictionary: 'Main dictionary',
|
||||
Frequency: '<ul style="text-align: left;"><li>Frequency: </li></ul>',
|
||||
Rank: '42',
|
||||
});
|
||||
assert.ok('dictionaryIds' in request);
|
||||
assert.deepEqual(request.dictionaryIds, {
|
||||
JMdict: 'terms',
|
||||
[characterDictionary]: 'names',
|
||||
Frequency: 'frequency',
|
||||
});
|
||||
assert.deepEqual(
|
||||
harness.messages.find((message) => message.type === 'hd_anki_submit')?.request,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.run(
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
|
||||
);
|
||||
await harness.run(
|
||||
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
|
||||
);
|
||||
const imported = harness.messages.find((message) => message.type === 'hd_import');
|
||||
assert.equal(imported?.fileName, 'characters.zip');
|
||||
assert.match(String(imported?.blobUrl), /^blob:/);
|
||||
const removed = harness.messages.find((message) => message.type === 'hd_remove');
|
||||
assert.equal(removed?.id, 'names');
|
||||
});
|
||||
|
||||
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
|
||||
const harness = await createHarness();
|
||||
harness.setLoadingStatusReplies(2);
|
||||
harness.setBusyEngineReplies(1);
|
||||
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
|
||||
assert.equal(dictionaries.length, 3);
|
||||
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
|
||||
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
|
||||
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
|
||||
assert.equal(tokens?.[0]?.headword, '食べる');
|
||||
assert.equal(tokens?.[0]?.frequencyRank, 42);
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
// Hachidori exposes its own storage and engine protocol. Adapt it only inside
|
||||
// SubMiner's hidden extension windows so the shared scanner keeps its matching,
|
||||
// character-name and frequency semantics without changing Hachidori's pages.
|
||||
import { HACHIDORI_ANKI_SETTINGS_SCRIPT } from './hachidori-anki-settings';
|
||||
|
||||
export const HACHIDORI_SESSION_PARTITION = 'persist:hachidori';
|
||||
|
||||
export const HACHIDORI_PARSER_BRIDGE_SCRIPT = String.raw`
|
||||
(async () => {
|
||||
if (globalThis.__subminerDictionarySendMessage) return;
|
||||
const { createApiHost } = await import('./api-host.js');
|
||||
await import('./reader-options.js');
|
||||
const send = async (type, fields = {}, target = 'hoshidicts-offscreen') => {
|
||||
const reply = await chrome.runtime.sendMessage({
|
||||
target, type, requestId: crypto.randomUUID(), ...fields,
|
||||
});
|
||||
if (!reply || reply.ok !== true) {
|
||||
throw new Error(reply?.error || 'Hachidori returned an invalid response');
|
||||
}
|
||||
return reply;
|
||||
};
|
||||
// Hachidori reports an empty library while its dictionaries load and rejects
|
||||
// engine requests during imports. Wait for a settled engine so SubMiner never
|
||||
// caches an empty dictionary list or a token-less scan.
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const waitForEngineReady = async () => {
|
||||
const deadline = Date.now() + 120000;
|
||||
for (;;) {
|
||||
const reply = await send('hd_status').catch(() => null);
|
||||
if (reply?.ready === true && reply.loading !== true) return;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(reply?.ready === true
|
||||
? 'Hachidori is still importing dictionaries' : 'Hachidori dictionary engine is not ready');
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
};
|
||||
const engine = async (type, fields = {}, target) => {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
await waitForEngineReady();
|
||||
try {
|
||||
return await send(type, fields, target);
|
||||
} catch (error) {
|
||||
if (attempt >= 3 || !/busy mutating/.test(error.message)) throw error;
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
};
|
||||
const readState = async () => (await engine('hd_state_read', {}, 'hoshidicts-worker')).state
|
||||
?? { revision: 0, dictionaries: [] };
|
||||
const readOptions = async () => {
|
||||
const stored = (await chrome.storage.local.get('options')).options;
|
||||
return { ...globalThis.HDReaderOptions.normaliseOptions(stored), revision: stored?.revision ?? 0 };
|
||||
};
|
||||
globalThis.__subminerSetAnkiProxyUrl = async url => {
|
||||
const previous = (await chrome.storage.local.get('subminerAnkiProxyUrl')).subminerAnkiProxyUrl;
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: url });
|
||||
return typeof previous === 'string' ? previous : null;
|
||||
};
|
||||
${HACHIDORI_ANKI_SETTINGS_SCRIPT}
|
||||
const api = createApiHost({
|
||||
engine: fields => engine(fields.type, fields),
|
||||
render: fields => send(fields.type, fields, 'hachidori-anki-render'),
|
||||
readDictionaries: async () => (await readState()).dictionaries,
|
||||
readAudioSources: async () => (await readOptions()).audioSources.filter(source => source.enabled),
|
||||
version: chrome.runtime.getManifest().version,
|
||||
});
|
||||
// Snapshot of the last projection SubMiner received, so a later write can
|
||||
// be reduced to the fields SubMiner actually changed.
|
||||
let lastProjection = null;
|
||||
async function optionsGetFull() {
|
||||
const [state, options] = await Promise.all([readState(), readOptions()]);
|
||||
const revisions = { dictionaries: state.revision, options: options.revision };
|
||||
lastProjection = {
|
||||
revisions,
|
||||
enabledByTitle: Object.fromEntries(state.dictionaries.map(entry => [entry.title, entry.enabled !== false])),
|
||||
server: options.anki.url,
|
||||
deckByFormat: Object.fromEntries(options.anki.templates.map(template => [template.id, template.deck])),
|
||||
};
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
hachidoriRevisions: revisions,
|
||||
profiles: [{ name: 'Hachidori', options: {
|
||||
scanning: { length: options.scanLength },
|
||||
dictionaries: state.dictionaries.map((entry, index) => ({
|
||||
name: entry.title, alias: entry.displayName || entry.title,
|
||||
id: index, enabled: entry.enabled !== false,
|
||||
})),
|
||||
anki: {
|
||||
server: options.anki.url,
|
||||
cardFormats: options.anki.templates.map(template => ({
|
||||
id: template.id, type: 'term', enabled: true, deck: template.deck,
|
||||
})),
|
||||
},
|
||||
} }],
|
||||
};
|
||||
}
|
||||
async function setAllSettings(value) {
|
||||
const projected = value.profiles?.[0]?.options;
|
||||
const revisions = value.hachidoriRevisions;
|
||||
if (!projected || !revisions) throw new Error('Invalid Hachidori settings projection');
|
||||
const base = lastProjection
|
||||
&& lastProjection.revisions.dictionaries === revisions.dictionaries
|
||||
&& lastProjection.revisions.options === revisions.options ? lastProjection : null;
|
||||
const enabledByTitle = Object.fromEntries(projected.dictionaries.map(item => [item.name, item.enabled === true]));
|
||||
const deckByFormat = Object.fromEntries(projected.anki.cardFormats.map(format => [format.id, format.deck]));
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
const [state, options] = await Promise.all([readState(), readOptions()]);
|
||||
const current = state.revision === revisions.dictionaries && options.revision === revisions.options;
|
||||
if (!current && !base) {
|
||||
throw new Error('Hachidori settings changed while SubMiner was updating them; retry the action');
|
||||
}
|
||||
// Once Hachidori has moved on, apply only SubMiner's own changes on top
|
||||
// of the newer settings instead of replaying the stale projection.
|
||||
const enabledFor = title => enabledByTitle[title] === true;
|
||||
const dictionaries = state.dictionaries.map(entry =>
|
||||
current || (base.enabledByTitle[entry.title] === true) !== enabledFor(entry.title)
|
||||
? { ...entry, enabled: enabledFor(entry.title) } : entry);
|
||||
const templates = options.anki.templates.map(template => {
|
||||
const deck = deckByFormat[template.id];
|
||||
return deck !== undefined && (current || base.deckByFormat[template.id] !== deck)
|
||||
? { ...template, deck } : template;
|
||||
});
|
||||
const url = current || base.server !== projected.anki.server ? projected.anki.server : options.anki.url;
|
||||
const anki = { ...options.anki, url, templates, deck: templates[0]?.deck ?? options.anki.deck };
|
||||
try {
|
||||
if (JSON.stringify(dictionaries) !== JSON.stringify(state.dictionaries)) {
|
||||
await engine('hd_apply_state', { baseRevision: state.revision, dictionaries });
|
||||
}
|
||||
if (JSON.stringify(anki) !== JSON.stringify(options.anki)) {
|
||||
await engine('hd_options_write', { baseRevision: options.revision, options: { anki } }, 'hoshidicts-worker');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A write raced another Hachidori change; re-read once and reapply the delta.
|
||||
if (attempt > 0 || !base) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
async function getTermFrequencies({ termReadingList, dictionaries }) {
|
||||
const terms = [...new Set(termReadingList.map(pair => pair.term))];
|
||||
const { results } = await api({ type: 'hd_api_term_entries', terms });
|
||||
const frequencies = [];
|
||||
for (const result of results) {
|
||||
const term = terms[result.index];
|
||||
const pairs = termReadingList.filter(pair => pair.term === term);
|
||||
for (const entry of result.dictionaryEntries) {
|
||||
for (const value of entry.frequencies) {
|
||||
const headword = entry.headwords[value.headwordIndex];
|
||||
if (!headword || headword.term !== term || !dictionaries.includes(value.dictionary)) continue;
|
||||
if (!pairs.some(pair => pair.reading === null || pair.reading === headword.reading)) continue;
|
||||
// Upstream does not expose the frequency entry's original reading.
|
||||
// Keep its API flag and associate the value with the matched headword.
|
||||
frequencies.push({ term, reading: headword.reading || null,
|
||||
hasReading: value.hasReading, dictionary: value.dictionary,
|
||||
frequency: value.frequency, displayValue: value.displayValue,
|
||||
displayValueParsed: value.displayValueParsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
return frequencies;
|
||||
}
|
||||
// Hachidori's public tokenize API emits display furigana without headwords.
|
||||
// SubMiner's fallback requires one group per token and a dictionary form.
|
||||
async function parseText({ text, scanLength }) {
|
||||
const content = [];
|
||||
let position = 0;
|
||||
while (position < text.length) {
|
||||
const rest = text.slice(position);
|
||||
const reply = await engine('hd_lookup', { text: rest, maxResults: 1, scanLength });
|
||||
const result = reply.results[0];
|
||||
if (!result?.matched || !rest.startsWith(result.matched)) {
|
||||
const character = String.fromCodePoint(rest.codePointAt(0));
|
||||
content.push([{ text: character, reading: '' }]);
|
||||
position += character.length;
|
||||
continue;
|
||||
}
|
||||
const term = result.term;
|
||||
// Keep the inflected ending in the reading, just as the main scanner does.
|
||||
let stem = 0;
|
||||
while (stem < term.expression.length && stem < result.matched.length && term.expression[stem] === result.matched[stem]) stem += 1;
|
||||
const ending = term.expression.slice(stem);
|
||||
const reading = stem > 0 && term.reading.endsWith(ending)
|
||||
? term.reading.slice(0, term.reading.length - ending.length) + result.matched.slice(stem)
|
||||
: term.reading;
|
||||
content.push([{ text: result.matched, reading, headwords: [[{ term: term.expression }]] }]);
|
||||
position += result.matched.length;
|
||||
}
|
||||
return [{ source: 'scanning-parser', index: 0, content }];
|
||||
}
|
||||
async function invoke(action, params) {
|
||||
switch (action) {
|
||||
case 'optionsGetFull': return optionsGetFull();
|
||||
case 'setAllSettings': return setAllSettings(params.value);
|
||||
case 'getDictionaryInfo': return (await readState()).dictionaries.map(entry => ({
|
||||
title: entry.title, revision: entry.revision, frequencyMode: entry.frequencyMode,
|
||||
}));
|
||||
case 'termsFind': {
|
||||
const reply = await api({ type: 'hd_api_term_entries', terms: [params.text] });
|
||||
return reply.results[0];
|
||||
}
|
||||
case 'parseText': return parseText(params);
|
||||
case 'getTermFrequencies': return getTermFrequencies(params);
|
||||
default: throw new Error('Unsupported Hachidori parser action: ' + action);
|
||||
}
|
||||
}
|
||||
globalThis.__subminerDictionarySendMessage = ({ action, params }, callback) => {
|
||||
void invoke(action, params).then(result => callback({ result }), error => callback({ error: { message: error.message } }));
|
||||
};
|
||||
async function importArchive(blob, fileName) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const reply = await engine('hd_import', { blobUrl, fileName });
|
||||
if (reply.report?.success !== true) throw new Error(reply.report?.error || 'Hachidori dictionary import failed');
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
}
|
||||
globalThis.__subminerYomitanSettingsAutomation = {
|
||||
ready: true,
|
||||
async importDictionaryArchiveUrl(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Could not read the dictionary archive');
|
||||
await importArchive(await response.blob(), 'subminer-dictionary.zip');
|
||||
},
|
||||
async importDictionaryArchiveBase64(base64, fileName) {
|
||||
const bytes = Uint8Array.from(atob(base64), character => character.charCodeAt(0));
|
||||
await importArchive(new Blob([bytes], { type: 'application/zip' }), fileName);
|
||||
},
|
||||
async deleteDictionary(title) {
|
||||
const dictionary = (await readState()).dictionaries.find(entry => entry.title === title);
|
||||
if (dictionary) await engine('hd_remove', { id: dictionary.id, title });
|
||||
},
|
||||
};
|
||||
globalThis.__subminerAddNote = async word => {
|
||||
const lookup = await engine('hd_lookup', { text: word, maxResults: 1 });
|
||||
const result = lookup.results[0];
|
||||
if (!result) return { noteId: null, duplicateNoteIds: [] };
|
||||
// Match the dictionary context supplied by Hachidori's popup to its
|
||||
// native glossary, alias, and frequency template renderers.
|
||||
const { dictionaries } = await readState();
|
||||
const frequencyModes = new Map(dictionaries.map(entry => [entry.title, entry.frequencyMode]));
|
||||
const term = { ...result.term, frequencies: result.term.frequencies.map(group =>
|
||||
({ ...group, frequencyMode: frequencyModes.get(group.dictionary) })) };
|
||||
const status = await send('hd_anki_status', {}, 'hachidori-anki');
|
||||
const request = {
|
||||
...result, term, generation: lookup.generation, sentence: word, searchQuery: word,
|
||||
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
|
||||
configKey: status.configKey, subminerEnrich: false,
|
||||
dictionaryAliases: Object.fromEntries(dictionaries.filter(entry => entry.displayName)
|
||||
.map(entry => [entry.title, entry.displayName])),
|
||||
dictionaryIds: Object.fromEntries(dictionaries.map(entry => [entry.title, entry.id])),
|
||||
frequencyDictionaries: dictionaries.filter(entry => entry.enabled !== false && entry.frequencyCount > 0)
|
||||
.map(entry => entry.title),
|
||||
captureUnavailable: ['screenshot', 'animation', 'audio'],
|
||||
};
|
||||
const preflight = await send('hd_anki_preflight', { request }, 'hachidori-anki');
|
||||
if (preflight.canAdd !== true) {
|
||||
if (preflight.state !== 'duplicate') throw new Error(preflight.error || 'Hachidori Anki mining is unavailable');
|
||||
return { noteId: null, duplicateNoteIds: preflight.noteIds ?? [] };
|
||||
}
|
||||
const submitted = await send('hd_anki_submit', { request }, 'hachidori-anki');
|
||||
if (submitted.state === 'added' || submitted.state === 'updated') {
|
||||
return { noteId: submitted.noteId, duplicateNoteIds: [] };
|
||||
}
|
||||
if (submitted.state === 'duplicate') return { noteId: null, duplicateNoteIds: submitted.noteIds ?? [] };
|
||||
throw new Error(submitted.error || 'Hachidori could not confirm the note write');
|
||||
};
|
||||
})();
|
||||
`;
|
||||
@@ -3,7 +3,6 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import {
|
||||
countTermsFindLookups,
|
||||
createDeps,
|
||||
@@ -24,102 +23,6 @@ import {
|
||||
upsertYomitanDictionarySettings,
|
||||
} from './yomitan-parser-runtime';
|
||||
|
||||
test('Yomitan restores direct Anki after disabling its managed proxy without a page helper', async () => {
|
||||
const options = { profiles: [{ options: { anki: { server: 'http://127.0.0.1:8765' } } }] };
|
||||
let managedUrl: string | null = null;
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
|
||||
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
|
||||
managedUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: (
|
||||
message: { action: string },
|
||||
callback: (response: { result: unknown }) => void,
|
||||
) => callback({ result: message.action === 'optionsGetFull' ? options : null }),
|
||||
},
|
||||
},
|
||||
});
|
||||
const deps = createDeps(async (script) =>
|
||||
structuredClone(await vm.runInContext(script, context)),
|
||||
);
|
||||
const logger = { error: assert.fail };
|
||||
assert.equal(
|
||||
await syncYomitanDefaultAnkiServer('http://127.0.0.1:8766', deps, logger, {
|
||||
forceOverride: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(managedUrl, 'http://127.0.0.1:8766');
|
||||
assert.equal(await syncYomitanDefaultAnkiServer('http://127.0.0.1:8765', deps, logger), true);
|
||||
assert.equal(options.profiles[0]?.options.anki.server, 'http://127.0.0.1:8765');
|
||||
assert.equal(managedUrl, null);
|
||||
});
|
||||
|
||||
for (const failure of ['optionsGetFull', 'setAllSettings', 'storageSet']) {
|
||||
test(`Yomitan retries disabling its managed proxy after ${failure} fails`, async () => {
|
||||
const proxyUrl = 'http://127.0.0.1:8766';
|
||||
const directUrl = 'http://127.0.0.1:8765';
|
||||
let options = { profiles: [{ options: { anki: { server: proxyUrl } } }] };
|
||||
let managedUrl: string | null = proxyUrl;
|
||||
let shouldFail = true;
|
||||
const context = vm.createContext({
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ subminerAnkiProxyUrl: managedUrl }),
|
||||
set: async (value: { subminerAnkiProxyUrl: string | null }) => {
|
||||
if (shouldFail && failure === 'storageSet') throw new Error('Storage unavailable');
|
||||
managedUrl = value.subminerAnkiProxyUrl;
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: (
|
||||
message: { action: string; params?: { value: typeof options } },
|
||||
callback: (response: { result?: unknown; error?: { message: string } }) => void,
|
||||
) => {
|
||||
if (shouldFail && message.action === failure) {
|
||||
callback({ error: { message: 'Settings unavailable' } });
|
||||
} else if (message.action === 'optionsGetFull') {
|
||||
callback({ result: structuredClone(options) });
|
||||
} else if (message.action === 'setAllSettings' && message.params) {
|
||||
options = structuredClone(message.params.value);
|
||||
callback({ result: null });
|
||||
} else {
|
||||
assert.fail(`Unexpected action: ${message.action}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const deps = createDeps(async (script) =>
|
||||
structuredClone(await vm.runInContext(script, context)),
|
||||
);
|
||||
const errors: string[] = [];
|
||||
const logger = { error: (message: string) => errors.push(message) };
|
||||
|
||||
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), false);
|
||||
assert.equal(managedUrl, proxyUrl);
|
||||
assert.equal(
|
||||
options.profiles[0]?.options.anki.server,
|
||||
failure === 'storageSet' ? directUrl : proxyUrl,
|
||||
);
|
||||
assert.equal(errors.length, 1);
|
||||
|
||||
shouldFail = false;
|
||||
assert.equal(await syncYomitanDefaultAnkiServer(directUrl, deps, logger), true);
|
||||
assert.equal(options.profiles[0]?.options.anki.server, directUrl);
|
||||
assert.equal(managedUrl, null);
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('syncYomitanDefaultAnkiServer updates default profile server when script reports update', async () => {
|
||||
let scriptValue = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
|
||||
import { uploadHachidoriDictionary } from './hachidori-dictionary-import';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
parseHachidoriHostStatus,
|
||||
type HachidoriSharingRequest,
|
||||
} from '../../../shared/hachidori-sharing';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
HACHIDORI_PARSER_BRIDGE_SCRIPT,
|
||||
HACHIDORI_SESSION_PARTITION,
|
||||
} from './hachidori-parser-bridge';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
import {
|
||||
buildYomitanScanCallScript,
|
||||
@@ -489,7 +477,7 @@ async function requestYomitanProfileMetadata(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -608,18 +596,6 @@ function logYomitanProfileDiagnostics(
|
||||
logger.info?.('Yomitan active profile dictionaries loaded.', details);
|
||||
}
|
||||
|
||||
function isHachidoriExtension(extension: Extension): boolean {
|
||||
return extension.name === 'Hachidori';
|
||||
}
|
||||
|
||||
// Without an explicit session, use the one the extension was loaded into:
|
||||
// Hachidori lives in its own partition, Yomitan in the default session.
|
||||
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
|
||||
return isHachidoriExtension(extension)
|
||||
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
|
||||
: electron.session.defaultSession;
|
||||
}
|
||||
|
||||
async function ensureYomitanParserWindow(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
@@ -630,20 +606,19 @@ async function ensureYomitanParserWindow(
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingInitPromise = deps.getYomitanParserInitPromise();
|
||||
if (existingInitPromise) {
|
||||
return existingInitPromise;
|
||||
}
|
||||
|
||||
const currentWindow = deps.getYomitanParserWindow();
|
||||
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingInitPromise = deps.getYomitanParserInitPromise();
|
||||
if (existingInitPromise) {
|
||||
return existingInitPromise;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession =
|
||||
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const parserWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
@@ -674,27 +649,11 @@ async function ensureYomitanParserWindow(
|
||||
});
|
||||
|
||||
try {
|
||||
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
|
||||
const readyPromise = deps.getYomitanParserReadyPromise();
|
||||
if (readyPromise) {
|
||||
await readyPromise;
|
||||
}
|
||||
if (isHachidoriExtension(yomitanExt)) {
|
||||
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
} else {
|
||||
// did-finish-load precedes the search page's asynchronous backend initialization.
|
||||
await parserWindow.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (typeof window.__subminerAddNote !== 'function') {
|
||||
if (Date.now() >= deadline) throw new Error('Yomitan search page initialization timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
})()`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Eagerly install the scan runtime so the first subtitle line does not
|
||||
// pay the install round trip; failures fall back to the per-request
|
||||
// install-and-retry path.
|
||||
@@ -737,8 +696,8 @@ async function createYomitanExtensionWindow(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
@@ -781,10 +740,6 @@ async function invokeYomitanSettingsAutomation<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (extension && isHachidoriExtension(extension)) {
|
||||
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
}
|
||||
await settingsWindow.webContents.executeJavaScript(
|
||||
`
|
||||
(async () => {
|
||||
@@ -937,7 +892,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -967,7 +922,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1124,7 +1079,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1167,7 +1122,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1251,17 +1206,7 @@ function cacheFrequencyEntriesForPairs(
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
// Untagged frequency rows apply to every reading. A term-only query must
|
||||
// retain all readings, rather than selecting only its untagged rows.
|
||||
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
|
||||
frequencyCache.set(
|
||||
key,
|
||||
pair.reading === null
|
||||
? termEntries
|
||||
: exactEntries
|
||||
? [...exactEntries, ...untaggedEntries]
|
||||
: termEntries,
|
||||
);
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1395,7 +1340,6 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
options?: {
|
||||
forceOverride?: boolean;
|
||||
deck?: string;
|
||||
ankiConfig?: AnkiConnectConfig;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
@@ -1415,7 +1359,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1435,13 +1379,6 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
|
||||
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||
const hachidoriHints = ${JSON.stringify(options?.ankiConfig ? buildHachidoriAnkiHints(options.ankiConfig) : null)};
|
||||
if (hachidoriHints && typeof globalThis.__subminerSyncAnkiSettings === 'function') {
|
||||
return globalThis.__subminerSyncAnkiSettings({
|
||||
server: targetServer, deck: targetDeck, forceOverride, hints: hachidoriHints,
|
||||
});
|
||||
}
|
||||
const { subminerAnkiProxyUrl: previousManagedProxy } = await chrome.storage.local.get('subminerAnkiProxyUrl');
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
@@ -1468,11 +1405,8 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
let changed = false;
|
||||
if (currentServer !== targetServer) {
|
||||
const canReplaceCurrent =
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
|
||||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
if (!canReplaceCurrent) {
|
||||
// A custom endpoint needs no settings change, but the proxy is no longer managed.
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: null });
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
}
|
||||
|
||||
@@ -1510,28 +1444,17 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
}
|
||||
// Preserve the previous managed endpoint until settings are saved so failed switches can retry.
|
||||
await chrome.storage.local.set({ subminerAnkiProxyUrl: forceOverride ? targetServer : null });
|
||||
|
||||
if (!changed) {
|
||||
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
|
||||
}
|
||||
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
if (isObject(result) && result.pending === true) {
|
||||
logger.info?.(
|
||||
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const updated =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
@@ -1641,7 +1564,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1663,25 +1586,6 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
`;
|
||||
}
|
||||
|
||||
export async function requestHachidoriSharing(
|
||||
request: HachidoriSharingRequest,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
) {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
|
||||
const ready = await ensureYomitanParserWindow(deps, logger);
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
|
||||
const reply: unknown = await window.webContents.executeJavaScript(
|
||||
buildHachidoriSharingScript(request),
|
||||
true,
|
||||
);
|
||||
const status = parseHachidoriHostStatus(reply);
|
||||
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
|
||||
return status;
|
||||
}
|
||||
|
||||
async function invokeYomitanBackendAction<T>(
|
||||
action: string,
|
||||
params: unknown,
|
||||
@@ -1800,7 +1704,6 @@ export async function importYomitanDictionaryFromZip(
|
||||
zipPath: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
hachidoriManagementUrl = '',
|
||||
): Promise<boolean> {
|
||||
const normalizedZipPath = zipPath.trim();
|
||||
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
|
||||
@@ -1808,30 +1711,6 @@ export async function importYomitanDictionaryFromZip(
|
||||
return false;
|
||||
}
|
||||
|
||||
const extension = deps.getYomitanExt();
|
||||
if (extension && isHachidoriExtension(extension)) {
|
||||
try {
|
||||
const host = await requestHachidoriSharing({ type: 'hd_sharing_status' }, deps, logger);
|
||||
if (host.kind === 'disconnected' || host.kind === 'unavailable')
|
||||
throw new Error(host.message);
|
||||
if (host.kind === 'connected') {
|
||||
await uploadHachidoriDictionary(normalizedZipPath, hachidoriManagementUrl);
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (window) clearYomitanParserCachesForWindow(window);
|
||||
logger.info?.(
|
||||
`Uploaded character dictionary to Hachidori host: ${path.basename(normalizedZipPath)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Hachidori character dictionary import failed:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
|
||||
`
|
||||
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
|
||||
|
||||
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||
|
||||
// Bump whenever the install script below changes so already-loaded parser
|
||||
// windows re-install the new scan runtime instead of running the stale one.
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
|
||||
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||
|
||||
export interface YomitanScanRequestParams {
|
||||
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||
}
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
|
||||
@@ -30,17 +30,8 @@ export function createDeps(
|
||||
}
|
||||
|
||||
function createYomitanScriptSandbox(handler: (action: string, params: unknown) => unknown) {
|
||||
const storage: Record<string, unknown> = {};
|
||||
return {
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({ ...storage }),
|
||||
set: async (value: Record<string, unknown>) => {
|
||||
Object.assign(storage, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
lastError: null,
|
||||
sendMessage: (
|
||||
|
||||
@@ -16,7 +16,6 @@ type ExtensionCopyResult = {
|
||||
|
||||
type ExtensionCopyOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extensionName?: 'yomitan' | 'hachidori';
|
||||
};
|
||||
|
||||
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
|
||||
@@ -157,7 +156,7 @@ export function ensureExtensionCopy(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
|
||||
let shouldCopy = !fs.existsSync(targetDir);
|
||||
if (!shouldCopy) {
|
||||
@@ -183,7 +182,7 @@ export async function ensureExtensionCopyAsync(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const inFlightKey = path.resolve(targetDir);
|
||||
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
|
||||
@@ -188,11 +188,6 @@ export async function loadYomitanExtension(
|
||||
deps.setYomitanSession(targetSession);
|
||||
|
||||
try {
|
||||
if (!externalProfilePath) {
|
||||
// Electron may retain old extension scripts after an update, across app restarts.
|
||||
// Keep dictionaries/settings while ensuring the bundled worker loads current code.
|
||||
await targetSession.clearStorageData({ storages: ['serviceworkers'] });
|
||||
}
|
||||
const extensions = targetSession.extensions;
|
||||
const extension = await withSuppressedYomitanExtensionWarnings(() =>
|
||||
extensions
|
||||
|
||||
@@ -7,7 +7,6 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
|
||||
const logger = createLogger('main:yomitan-settings');
|
||||
|
||||
export interface OpenYomitanSettingsWindowOptions {
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
yomitanExt: Extension | null;
|
||||
getExistingWindow: () => BrowserWindow | null;
|
||||
setWindow: (window: BrowserWindow | null) => void;
|
||||
@@ -24,7 +23,6 @@ type HyprlandSessionEnv = {
|
||||
export interface InstallYomitanSettingsCloseButtonOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: HyprlandSessionEnv;
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
}
|
||||
|
||||
export function shouldInstallYomitanSettingsCloseButton(
|
||||
@@ -55,9 +53,7 @@ export function buildYomitanSettingsWindowMenuTemplate(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsCloseButtonScript(
|
||||
backend: 'yomitan' | 'hachidori' = 'yomitan',
|
||||
): string {
|
||||
export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
return `
|
||||
(() => {
|
||||
const buttonId = 'subminer-yomitan-settings-close';
|
||||
@@ -101,7 +97,7 @@ export function buildYomitanSettingsCloseButtonScript(
|
||||
button.id = buttonId;
|
||||
button.type = 'button';
|
||||
button.title = 'Close';
|
||||
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
|
||||
button.setAttribute('aria-label', 'Close Yomitan settings');
|
||||
button.textContent = '\\u00d7';
|
||||
button.addEventListener('click', () => {
|
||||
window.close();
|
||||
@@ -122,7 +118,7 @@ export function installYomitanSettingsCloseButton(
|
||||
return;
|
||||
}
|
||||
settingsWindow.webContents
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
|
||||
.catch((error: Error) => {
|
||||
logger.warn('Failed to install Yomitan settings close button:', error.message);
|
||||
});
|
||||
@@ -188,7 +184,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
|
||||
|
||||
const settingsWindow = new ElectronBrowserWindow({
|
||||
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
|
||||
title: 'Yomitan Settings',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
@@ -232,7 +228,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
settingsWindow.webContents.on('did-finish-load', () => {
|
||||
logger.info('Settings page loaded successfully');
|
||||
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
|
||||
installYomitanSettingsCloseButton(settingsWindow);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openTsukihime: string | null | undefined;
|
||||
openSubtitleSelection: string | null | undefined;
|
||||
openSubtitleGeneration: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
openControllerSelect: string | null | undefined;
|
||||
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||
openSubtitleSelection:
|
||||
config.subtitleSelection?.enabled === true
|
||||
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
|
||||
: null,
|
||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
|
||||
+71
-191
@@ -1,4 +1,3 @@
|
||||
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
|
||||
/*
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2026 sudacode
|
||||
@@ -33,14 +32,6 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from './shared/dictionary-external-link';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
@@ -475,6 +466,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
||||
import {
|
||||
createSubtitleSelectionRuntime,
|
||||
openSubtitleSelectionModal,
|
||||
registerSubtitleSelectionIpc,
|
||||
} from './main/runtime/subtitle-selection';
|
||||
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||
@@ -915,34 +911,14 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
// Backend changes take effect on restart; windows and parser must share one session.
|
||||
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
|
||||
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
|
||||
let hachidoriSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanExtension: Extension | null = null;
|
||||
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
|
||||
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
|
||||
if (
|
||||
activeDictionaryBackend !== 'hachidori' ||
|
||||
event.senderFrame !== event.sender.mainFrame ||
|
||||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
|
||||
) {
|
||||
throw new Error('Dictionary links are only available from the active overlay');
|
||||
}
|
||||
await shell.openExternal(parseDictionaryExternalUrl(value));
|
||||
});
|
||||
|
||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||
return [
|
||||
appState.configSettingsWindow,
|
||||
appState.syncUiWindow,
|
||||
appState.yomitanSettingsWindow,
|
||||
hachidoriSettingsWindow,
|
||||
inactiveYomitanSettingsWindow,
|
||||
appState.anilistSetupWindow,
|
||||
appState.jellyfinSetupWindow,
|
||||
appState.firstRunSetupWindow,
|
||||
@@ -1040,8 +1016,6 @@ const {
|
||||
} = statsServerRuntime;
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
|
||||
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
@@ -1486,15 +1460,6 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
: undefined,
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
getHachidoriHostStatus: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return requestHachidoriSharing(
|
||||
{ type: 'hd_sharing_status' },
|
||||
getYomitanParserRuntimeDeps(),
|
||||
logger,
|
||||
);
|
||||
},
|
||||
platform: process.platform,
|
||||
configDir: CONFIG_DIR,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
@@ -2317,11 +2282,6 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
|
||||
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
|
||||
logger.error('Failed to auto-populate Hachidori Anki settings', error),
|
||||
);
|
||||
}
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
@@ -2668,18 +2628,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
return false;
|
||||
}
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await importYomitanDictionaryFromZip(
|
||||
zipPath,
|
||||
getYomitanParserRuntimeDeps(),
|
||||
{
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
},
|
||||
configService.getConfig().hachidori.externalHostManagementUrl,
|
||||
);
|
||||
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
},
|
||||
dictionaryImportReplacesExisting: () =>
|
||||
getYomitanParserRuntimeDeps().getYomitanExt()?.name === 'Hachidori',
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
yomitanProfilePolicy.logSkippedWrite(
|
||||
@@ -3502,8 +3455,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return {
|
||||
configReady: snapshot.configReady,
|
||||
dictionaryCount: snapshot.dictionaryCount,
|
||||
dictionaryBackend: snapshot.dictionaryBackend,
|
||||
hachidoriHost: snapshot.hachidoriHost,
|
||||
canFinish: snapshot.canFinish,
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
@@ -3519,33 +3470,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||
if (
|
||||
submission.action === 'link-hachidori-host' ||
|
||||
submission.action === 'unlink-hachidori-host'
|
||||
) {
|
||||
try {
|
||||
if (activeDictionaryBackend !== 'hachidori')
|
||||
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
|
||||
await ensureYomitanExtensionLoaded();
|
||||
const status = await requestHachidoriSharing(
|
||||
submission.action === 'link-hachidori-host'
|
||||
? { type: 'hd_sharing_client_link', address: submission.address }
|
||||
: { type: 'hd_sharing_client_unlink' },
|
||||
getYomitanParserRuntimeDeps(),
|
||||
logger,
|
||||
);
|
||||
firstRunSetupMessage =
|
||||
status.kind === 'local'
|
||||
? 'Using dictionaries installed in SubMiner.'
|
||||
: status.kind === 'connected'
|
||||
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
|
||||
: status.message;
|
||||
} catch (error) {
|
||||
firstRunSetupMessage =
|
||||
error instanceof Error ? error.message : 'Could not update the dictionary host.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'remove-legacy-plugin') {
|
||||
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
@@ -3586,8 +3510,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = openDictionarySettings()
|
||||
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
|
||||
firstRunSetupMessage = openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
@@ -4664,6 +4588,7 @@ const {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
void refreshMpvSessionBindings();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5238,41 +5163,6 @@ function initializeOverlayRuntime(): void {
|
||||
}
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
|
||||
return false;
|
||||
}
|
||||
inactiveYomitanLoad ??= inactiveYomitanExtension
|
||||
? Promise.resolve(inactiveYomitanExtension)
|
||||
: loadYomitanExtensionCore({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: (extension) => {
|
||||
inactiveYomitanExtension = extension;
|
||||
},
|
||||
setYomitanSession: () => {},
|
||||
}).finally(() => {
|
||||
inactiveYomitanLoad = null;
|
||||
});
|
||||
void (
|
||||
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => inactiveYomitanSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
inactiveYomitanSettingsWindow = window;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
|
||||
return true;
|
||||
}
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
logger.warn(
|
||||
@@ -5285,39 +5175,6 @@ function openYomitanSettings(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function openHachidoriSettings(): void {
|
||||
void (
|
||||
activeDictionaryBackend === 'hachidori'
|
||||
? ensureYomitanExtensionLoaded()
|
||||
: hachidoriExtensionRuntime.ensureLoaded()
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
backend: 'hachidori',
|
||||
yomitanExt: extension,
|
||||
yomitanSession: getHachidoriSession(),
|
||||
getExistingWindow: () => hachidoriSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
hachidoriSettingsWindow = window;
|
||||
},
|
||||
onWindowClosed: () => {
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
|
||||
}
|
||||
|
||||
function openDictionarySettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
openHachidoriSettings();
|
||||
return true;
|
||||
}
|
||||
return openYomitanSettings();
|
||||
}
|
||||
|
||||
const { exportLogsFromTray } = createLogExportTrayRuntime({
|
||||
flushMpvLog: () => flushMpvLog(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
@@ -5345,7 +5202,7 @@ const {
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
}),
|
||||
@@ -5384,20 +5241,32 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||
createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onBindingsChanged: (bindings) =>
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
|
||||
onWarning: (warning) => {
|
||||
if (warning.kind !== 'conflict') return;
|
||||
overlayNotificationsRuntime.showOverlayNotification({
|
||||
id: `session-binding-conflict:${warning.path}`,
|
||||
title: 'Shortcut conflict',
|
||||
body: warning.message,
|
||||
variant: 'warning',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
@@ -5652,6 +5521,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openSubtitleSelection: () => {
|
||||
if (!configService.getConfig().subtitleSelection.enabled) return;
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSubtitleSelectionModal,
|
||||
'Subtitle selection overlay unavailable.',
|
||||
'Failed to open subtitle selection overlay.',
|
||||
);
|
||||
},
|
||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
@@ -5836,7 +5713,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
@@ -6004,8 +5881,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getMpvInputBindings: () =>
|
||||
readMpvInputBindings({
|
||||
getMpvInputBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return readMpvInputBindings({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
||||
platform:
|
||||
@@ -6014,8 +5892,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
}),
|
||||
getSessionBindings: () => appState.sessionBindings,
|
||||
});
|
||||
},
|
||||
getSessionBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return appState.sessionBindings;
|
||||
},
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||
@@ -6277,7 +6159,6 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6453,8 +6334,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () =>
|
||||
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
@@ -6542,8 +6422,6 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6588,21 +6466,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
||||
});
|
||||
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
||||
externalProfilePath:
|
||||
activeDictionaryBackend === 'yomitan'
|
||||
? configService.getConfig().yomitan.externalProfilePath
|
||||
: '',
|
||||
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
|
||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore: async (deps) => {
|
||||
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
|
||||
const extension = await hachidoriExtensionRuntime.ensureLoaded();
|
||||
deps.setYomitanExtension(extension);
|
||||
deps.setYomitanSession(getHachidoriSession());
|
||||
return extension;
|
||||
},
|
||||
loadYomitanExtensionCore,
|
||||
userDataPath: USER_DATA_PATH,
|
||||
externalProfilePath: configuredExternalYomitanProfilePath,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
@@ -6811,6 +6680,17 @@ function setOverlayVisible(visible: boolean): void {
|
||||
}
|
||||
|
||||
registerIpcRuntimeHandlers();
|
||||
registerSubtitleSelectionIpc({
|
||||
ipc: ipcMain,
|
||||
isAllowedSender: (sender) =>
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
|
||||
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||
),
|
||||
runtime: createSubtitleSelectionRuntime({
|
||||
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
});
|
||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||
getModelDirectory: () =>
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -136,7 +135,6 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openHachidoriSettings: context.openHachidoriSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
|
||||
@@ -217,7 +217,6 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
@@ -431,7 +430,6 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openHachidoriSettings: params.ui.openHachidoriSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
|
||||
@@ -18,7 +18,6 @@ type MockWindow = {
|
||||
contentReady: boolean;
|
||||
documentLoaded: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
stopLoadingCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
@@ -38,7 +37,6 @@ function createMockWindow(): MockWindow & {
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitDidStopLoading: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
@@ -47,7 +45,7 @@ function createMockWindow(): MockWindow & {
|
||||
getURL: () => string;
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
isFocused: () => boolean;
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
|
||||
once: (event: 'did-finish-load', cb: () => void) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
} {
|
||||
@@ -67,7 +65,6 @@ function createMockWindow(): MockWindow & {
|
||||
contentReady: true,
|
||||
documentLoaded: true,
|
||||
loadCallbacks: [],
|
||||
stopLoadingCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
@@ -115,13 +112,6 @@ function createMockWindow(): MockWindow & {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitDidStopLoading: () => {
|
||||
state.loading = false;
|
||||
const callbacks = state.stopLoadingCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
@@ -143,11 +133,7 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
focused: false,
|
||||
isFocused: () => state.webContentsFocused,
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
|
||||
if (event === 'did-stop-loading') {
|
||||
state.stopLoadingCallbacks.push(cb);
|
||||
return;
|
||||
}
|
||||
once: (_event: 'did-finish-load', cb: () => void) => {
|
||||
state.loadCallbacks.push(cb);
|
||||
},
|
||||
focus: () => {
|
||||
@@ -906,44 +892,6 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
|
||||
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
|
||||
// before it; only did-stop-loading sees the settled state.
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
window.documentLoaded = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'r1' },
|
||||
{
|
||||
restoreOnModalClose: 'media-timing-review',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.documentLoaded = true;
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidStopLoading();
|
||||
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
|
||||
@@ -206,7 +206,6 @@ export function createOverlayModalRuntimeService(
|
||||
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
|
||||
modalWindowPrimeListenersRegistered.add(modalWindow);
|
||||
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
|
||||
}
|
||||
return true;
|
||||
@@ -282,11 +281,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||
// renderer can safely receive IPC as soon as its document has finished loading.
|
||||
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
|
||||
// fire even earlier), so a window created for this send would drop the message
|
||||
// without the did-stop-loading pass that follows once the load state settles.
|
||||
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
|
||||
deliver(() => isWindowLoadedForIpc(window));
|
||||
};
|
||||
|
||||
@@ -31,45 +31,6 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('replacement-capable imports retain the installed dictionary when an update fails', async () => {
|
||||
for (const succeeds of [true, false]) {
|
||||
let imported = false;
|
||||
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: makeTempDir(),
|
||||
getConfig: () => ({ enabled: true, maxLoaded: 3, profileScope: 'all' }),
|
||||
getOrCreateCurrentSnapshot: async () => ({
|
||||
mediaId: 7,
|
||||
mediaTitle: 'Frieren',
|
||||
entryCount: 100,
|
||||
fromCache: true,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
buildMergedDictionary: async () => ({
|
||||
zipPath: '/tmp/replacement.zip',
|
||||
revision: 'new',
|
||||
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||
entryCount: 100,
|
||||
}),
|
||||
getYomitanDictionaryInfo: async () => [
|
||||
{ title: 'SubMiner Character Dictionary', revision: 'old' },
|
||||
],
|
||||
dictionaryImportReplacesExisting: () => true,
|
||||
importYomitanDictionary: async () => {
|
||||
imported = true;
|
||||
return succeeds;
|
||||
},
|
||||
deleteYomitanDictionary: async () => {
|
||||
assert.fail('must keep the old dictionary until replacement succeeds');
|
||||
},
|
||||
upsertYomitanDictionarySettings: async () => true,
|
||||
now: () => 1000,
|
||||
});
|
||||
if (succeeds) await runtime.runSyncNow();
|
||||
else await assert.rejects(runtime.runSyncNow(), /Failed to import dictionary ZIP/);
|
||||
assert.equal(imported, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('character dictionary manager snapshots, reorders, and removes MRU entries', () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||
waitForYomitanMutationReady?: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
dictionaryImportReplacesExisting?: () => boolean;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
@@ -670,7 +669,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||
const importTimeoutMs = resolveImportTimeoutMs(
|
||||
merged?.zipPath ?? path.join(dictionariesDir, 'merged.zip'),
|
||||
);
|
||||
if (existing !== null && deps.dictionaryImportReplacesExisting?.() !== true) {
|
||||
if (existing !== null) {
|
||||
await withTimeout(
|
||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||
|
||||
@@ -72,7 +72,6 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -47,7 +47,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -108,7 +107,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -74,7 +74,6 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -103,7 +103,6 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -64,7 +64,6 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -145,7 +144,6 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openHachidoriSettings: () => deps.openHachidoriSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -56,7 +56,6 @@ function createDeps() {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user