mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-22 05:16:22 -07:00
feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support - Add launcher flags, documentation, packaging, and focused tests - Open on-demand overlay modals on the first attempt
This commit is contained in:
@@ -20,6 +20,8 @@ coverage/
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
/build/hachidori/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||
Integrates Yomitan or Hachidori with mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -25,6 +25,8 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
|
||||
|
||||
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
|
||||
|
||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
</div>
|
||||
@@ -275,7 +277,8 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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.
|
||||
- Hachidori integrates with subtitle scanning, popup controls, lookup tracking, character dictionaries, and Anki media enrichment, with separate dictionaries and settings for each backend.
|
||||
- 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.
|
||||
- Hachidori reloads its background code on startup so extension updates take effect while preserving installed dictionaries and settings.
|
||||
- First-run setup remembers each backend that finished it, 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.
|
||||
- Stats dashboard mining and deck lookup use the selected backend. 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 and media enrichment stay in SubMiner.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: dictionary
|
||||
|
||||
- Documented backend selection, separate Hachidori dictionary and Anki setup, the settings flags and shortcuts, and external host linking with local Anki mining and the running requirements for each host type.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Overlay modals such as the media timing review open on the first attempt when their dedicated window is created on demand, instead of after a four-second retry.
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
@@ -21,6 +21,14 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
|
||||
|
||||
### Reusing SubMiner settings in Hachidori
|
||||
|
||||
When Hachidori is the selected backend, SubMiner uses its Anki settings to populate Hachidori's first Anki template on startup and when opening its settings. It 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:
|
||||
@@ -141,6 +149,8 @@ Field names are matched against your Anki note type case-insensitively (an exact
|
||||
|
||||
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
|
||||
|
||||
When Hachidori mines through SubMiner's Anki proxy, it prepares downloadable word audio before saving the note so the animation delay can be measured on the first mine. Configure a downloadable pronunciation source in Hachidori's Audio settings; browser speech cannot be saved into Anki by the SubMiner overlay. If pronunciation is unavailable, Hachidori reports a warning and the card has no word-audio hold.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
@@ -315,6 +325,8 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
|
||||
|
||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
|
||||
|
||||
Field grouping runs when a new note is added with known duplicates. With the Hachidori backend that is the popup's **Add anyway** choice; **Overwrite** updates the existing note in place and only receives media enrichment.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isKiku": {
|
||||
|
||||
@@ -171,6 +171,12 @@ 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.
|
||||
|
||||
### Logging
|
||||
|
||||
Control the minimum log level for runtime output:
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
|
||||
@@ -15,7 +15,7 @@ All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindi
|
||||
| Shortcut | Action | Scope | Configurable |
|
||||
| ------------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus | `shortcuts.toggleVisibleOverlayGlobal` |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global (registered with the OS) | Fixed (not configurable) |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | OS-global (registered with the OS) | Fixed (not configurable) |
|
||||
|
||||
::: tip
|
||||
`Alt+Shift+O` is dispatched by the overlay window and the mpv plugin, so it works from either surface without OS registration. Only `Alt+Shift+Y` is registered with the OS; if it conflicts with another application, that binding cannot be changed. All `shortcuts.*` keys hot-reload - no restart needed.
|
||||
@@ -124,7 +124,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix - press `y
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-o` | Open active dictionary settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
+25
-4
@@ -15,8 +15,8 @@ That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socke
|
||||
Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
||||
> See [Yomitan setup](#yomitan-setup) for details.
|
||||
> SubMiner requires at least one dictionary in the selected lookup backend.
|
||||
> See [Yomitan setup](#yomitan-setup) or [Hachidori setup](#hachidori-setup).
|
||||
|
||||
::: tip Anki card enrichment
|
||||
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
|
||||
@@ -174,6 +174,7 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle b
|
||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --hachidori # Open Hachidori settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
@@ -208,7 +209,7 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
|
||||
|
||||
### Windows mpv shortcut
|
||||
|
||||
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
|
||||
First-run setup creates the config file, then requires dictionaries in the selected backend before it can finish.
|
||||
|
||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||
@@ -314,6 +315,26 @@ 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. Older hosts without the standalone frequency query support frequency annotations through term lookups; frequency-only entries without a matching term require an updated host.
|
||||
|
||||
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.
|
||||
|
||||
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. 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.
|
||||
|
||||
Hachidori's own duplicate handling differs from Yomitan's. Choosing **Overwrite** in the Hachidori popup updates the existing note and SubMiner enriches its media, while **Add anyway** creates a new note and runs SubMiner's Kiku/Senren [field grouping](./anki-integration.md#field-grouping-kiku-senren). Mining from the stats dashboard uses the selected backend as well.
|
||||
|
||||
### YouTube playback
|
||||
|
||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
|
||||
@@ -408,7 +429,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
| Keybind | Action | Scope |
|
||||
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
|
||||
| `Alt+Shift+Y` | Open active dictionary settings | OS-global - registered with the system, works from any window |
|
||||
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
The dictionary backend is selected once at startup by `dictionaryBackend`. Yomitan keeps its existing session and external-profile policy. Hachidori uses `persist:hachidori`; overlay windows select that session before extension loading, including deferred startup. Named settings flags can open either backend without injecting a second reader into the active overlay. The detached stats word helper reads the same config key so dashboard mining uses the active backend.
|
||||
|
||||
`setup-state.json` records one backend's status at a time plus `completedDictionaryBackends`, the backends that finished setup before. The app projects the file onto its active backend on startup and stamps that backend into the file. The launcher gates playback on the stamped backend when an app is already running, since a config edit takes effect only after restart.
|
||||
|
||||
Hachidori's source, HoshiDicts engine, licenses, pinned revision, and local patch notes live in `vendor/hachidori/`. `build:hachidori` verifies recorded artifact checksums and stages the extension for development and packaging. 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 native runtime messages to the existing subtitle scanner and dictionary automation. Its local content bridge implements SubMiner's existing popup events and commands. 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,6 +63,7 @@ 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,7 +26,10 @@ import {
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
import {
|
||||
hasLauncherExternalYomitanProfileConfig,
|
||||
loadLauncherDictionaryBackend,
|
||||
} from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -115,6 +118,9 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: loadLauncherDictionaryBackend(),
|
||||
isAppRunning: () => isRunningAppControlServerAvailable(args.logLevel, configDir),
|
||||
warn: (message) => log('warn', args.logLevel, message),
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
hasLegacyMpvPlugin: () =>
|
||||
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
import { fail } from './log.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import type {
|
||||
Args,
|
||||
LauncherLoggingConfig,
|
||||
@@ -100,8 +101,17 @@ export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLauncherDictionaryBackend(): DictionaryBackend {
|
||||
return readLauncherMainConfigObject()?.dictionaryBackend === 'hachidori'
|
||||
? 'hachidori'
|
||||
: 'yomitan';
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
const config = readLauncherMainConfigObject();
|
||||
return (
|
||||
config?.dictionaryBackend !== 'hachidori' && readExternalYomitanProfilePath(config) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
|
||||
@@ -241,6 +241,10 @@ export function applyRootOptionsToArgs(
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
if (options.yomitan === true || options.hachidori === true) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = [options.yomitan === true ? '--yomitan' : '--hachidori'];
|
||||
}
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
|
||||
@@ -86,6 +86,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('--yomitan', 'Open Yomitan settings window')
|
||||
.option('--hachidori', 'Open Hachidori settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
|
||||
+22
-20
@@ -282,29 +282,31 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher settings option forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
for (const flag of ['--settings', '--yomitan', '--hachidori']) {
|
||||
test(`launcher ${flag} forwards the matching app settings command`, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--settings'], env);
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher([flag], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), `${flag}\n`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('launcher settings command forwards app settings window command', () => {
|
||||
withTempDir((root) => {
|
||||
|
||||
@@ -344,3 +344,12 @@ test('parseArgs requires an explicit logs action', () => {
|
||||
assert.equal(exit.code, 1);
|
||||
assert.match(exit.stderr, /Logs command requires -e or --export/);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`parseArgs forwards ${flag} to the app`, () => {
|
||||
const parsed = parseArgs([flag], 'subminer', {});
|
||||
assert.equal(parsed.appPassthrough, true);
|
||||
assert.deepEqual(parsed.appArgs, [flag]);
|
||||
assert.equal(parsed.settings, false);
|
||||
});
|
||||
}
|
||||
|
||||
+104
-2
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
import {
|
||||
ensureLauncherSetupReady,
|
||||
resolveLauncherGateBackend,
|
||||
waitForSetupCompletion,
|
||||
} from './setup-gate';
|
||||
import { createDefaultSetupState, type SetupState } from '../src/shared/setup-state';
|
||||
|
||||
const commandLineSetupDefaults = {
|
||||
bunInstallStatus: 'unknown',
|
||||
@@ -295,3 +299,101 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
|
||||
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
test('Hachidori setup ignores completed Yomitan state and external Yomitan profiles', async () => {
|
||||
let state: SetupState = { ...createDefaultSetupState(), status: 'completed' };
|
||||
let launched = 0;
|
||||
let polls = 0;
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => state,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
launched += 1;
|
||||
},
|
||||
sleep: async () => {
|
||||
polls += 1;
|
||||
state = { ...state, dictionaryBackend: 'hachidori', lastSeenYomitanDictionaryCount: 1 };
|
||||
},
|
||||
now: () => polls,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
assert.equal(launched, 1);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('matching Hachidori completion resumes playback without launching setup', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'completed',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('a backend that finished setup earlier passes the gate after switching back', async () => {
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'yomitan',
|
||||
readSetupState: () => ({
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'hachidori',
|
||||
status: 'incomplete',
|
||||
completedDictionaryBackends: ['yomitan'],
|
||||
}),
|
||||
launchSetupApp: () => assert.fail('setup should not open'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
test('gate follows the running app backend until it restarts into the configured one', async () => {
|
||||
const warnings: string[] = [];
|
||||
const state = {
|
||||
...createDefaultSetupState(),
|
||||
dictionaryBackend: 'yomitan' as const,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => true,
|
||||
warn: (message) => warnings.push(message),
|
||||
}),
|
||||
'yomitan',
|
||||
);
|
||||
assert.match(warnings[0] ?? '', /restart it to switch to hachidori/);
|
||||
assert.equal(
|
||||
await resolveLauncherGateBackend({
|
||||
configuredBackend: 'hachidori',
|
||||
state,
|
||||
isAppRunning: async () => false,
|
||||
}),
|
||||
'hachidori',
|
||||
);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
dictionaryBackend: 'hachidori',
|
||||
isAppRunning: async () => true,
|
||||
readSetupState: () => state,
|
||||
launchSetupApp: () => assert.fail('the running Yomitan app already completed setup'),
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
assert.equal(ready, true);
|
||||
});
|
||||
|
||||
+47
-6
@@ -1,7 +1,13 @@
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
import type { DictionaryBackend } from '../src/types/config.js';
|
||||
import {
|
||||
getSetupStateDictionaryBackend,
|
||||
isSetupCompleted,
|
||||
type SetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -13,7 +19,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
if (isSetupCompleted(state, deps.dictionaryBackend)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
|
||||
@@ -34,6 +40,7 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
@@ -41,13 +48,13 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompleted = isSetupCompleted(deps.initialState, deps.dictionaryBackend);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
isSetupCompleted(state, deps.dictionaryBackend) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
@@ -62,8 +69,34 @@ export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
/**
|
||||
* The app pins its dictionary backend at startup while the config file can change
|
||||
* underneath it. When an app is already running, gate on the backend it recorded
|
||||
* in the setup state rather than the config value it has not restarted into.
|
||||
*/
|
||||
export async function resolveLauncherGateBackend(deps: {
|
||||
configuredBackend: DictionaryBackend;
|
||||
state: SetupState | null;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<DictionaryBackend> {
|
||||
const runningBackend = deps.state
|
||||
? getSetupStateDictionaryBackend(deps.state)
|
||||
: deps.configuredBackend;
|
||||
if (runningBackend === deps.configuredBackend || !(await deps.isAppRunning?.())) {
|
||||
return deps.configuredBackend;
|
||||
}
|
||||
deps.warn?.(
|
||||
`SubMiner is running with the ${runningBackend} dictionary backend; restart it to switch to ${deps.configuredBackend}.`,
|
||||
);
|
||||
return runningBackend;
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
isAppRunning?: () => Promise<boolean>;
|
||||
warn?: (message: string) => void;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
@@ -73,6 +106,12 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
const dictionaryBackend = await resolveLauncherGateBackend({
|
||||
configuredBackend: deps.dictionaryBackend ?? 'yomitan',
|
||||
state: initialState,
|
||||
isAppRunning: deps.isAppRunning,
|
||||
warn: deps.warn,
|
||||
});
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
@@ -84,6 +123,7 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
dictionaryBackend,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
@@ -95,17 +135,18 @@ export async function ensureLauncherSetupReady(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
if (dictionaryBackend !== 'hachidori' && deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt, dictionaryBackend)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
dictionaryBackend,
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
|
||||
+8
-2
@@ -12,6 +12,7 @@
|
||||
"get-frequency": "bun run scripts/get_frequency.ts --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test:dictionary:electron": "env -u ELECTRON_RUN_AS_NODE electron --no-sandbox scripts/check-dictionary-backends.cjs && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox scripts/check-dictionary-backends.cjs --backend=yomitan && env -u ELECTRON_RUN_AS_NODE electron --no-sandbox --ozone-platform=x11 scripts/check-hachidori-parser.cjs",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||
@@ -21,7 +22,7 @@
|
||||
"build:launcher": "bun run scripts/build-launcher.ts",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:hachidori && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
@@ -81,7 +82,8 @@
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip --publish never",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip --publish never",
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs",
|
||||
"test:package": "bun scripts/run-package-smoke.mjs"
|
||||
"test:package": "bun scripts/run-package-smoke.mjs",
|
||||
"build:hachidori": "bun scripts/build-hachidori.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
@@ -232,6 +234,10 @@
|
||||
"from": "build/yomitan",
|
||||
"to": "yomitan"
|
||||
},
|
||||
{
|
||||
"from": "build/hachidori",
|
||||
"to": "hachidori"
|
||||
},
|
||||
{
|
||||
"from": "vendor/yomitan-jlpt-vocab",
|
||||
"to": "yomitan-jlpt-vocab"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 });
|
||||
for (const file of ['LICENSE', 'SOURCE.json', 'README.md']) {
|
||||
fs.copyFileSync(path.join(source, file), path.join(output, file));
|
||||
}
|
||||
process.stdout.write(`Hachidori ${provenance.revision} staged in ${output}\n`);
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 = fs.mkdtempSync('/tmp/subminer-hachi-settings-');
|
||||
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,
|
||||
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()),
|
||||
);
|
||||
app.exit(1);
|
||||
}, 60000);
|
||||
(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`,
|
||||
);
|
||||
|
||||
clearTimeout(deadline);
|
||||
app.exit(0);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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']);
|
||||
// Remove all term dictionaries before checking direct frequency queries.
|
||||
for (const title of ['SubMiner Test Terms', 'SubMiner Character Dictionary (AniList 1)']) {
|
||||
assert.equal(await parser.deleteYomitanDictionaryByTitle(title, deps, logger), true);
|
||||
}
|
||||
parser.clearYomitanParserCachesForWindow(parserWindow);
|
||||
const exact = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '頻度だけ', reading: 'ひんどだけ' }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.deepEqual(
|
||||
exact.map((value) => value.frequency).sort((a, b) => a - b),
|
||||
[17, 120],
|
||||
);
|
||||
assert.ok(
|
||||
exact.some(
|
||||
(value) => value.frequency === 120 && value.hasReading && value.reading === 'ひんどだけ',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
exact.some((value) => value.frequency === 17 && !value.hasReading && value.reading === null),
|
||||
);
|
||||
const allReadings = await parser.requestYomitanTermFrequencies(
|
||||
[{ term: '頻度だけ', reading: null }],
|
||||
deps,
|
||||
logger,
|
||||
);
|
||||
assert.deepEqual(
|
||||
allReadings.map((value) => value.frequency).sort((a, b) => a - b),
|
||||
[17, 120, 250],
|
||||
);
|
||||
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, frequency-only dictionaries, reading provenance, settings and removal',
|
||||
);
|
||||
clearTimeout(deadline);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(deadline);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import '../vendor/hachidori/extension/reader-options.js';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
import { createAnkiWorkerService } from '../vendor/hachidori/extension/anki-worker.js';
|
||||
|
||||
async function mine({ proxy = true, audioFails = false } = {}) {
|
||||
const events: string[] = [];
|
||||
const filename = `hachidori_${'a'.repeat(64)}.mp3`;
|
||||
let fields: Record<string, string> = {};
|
||||
let initialAudio = '';
|
||||
let stored = false;
|
||||
const gateway = createAnkiGateway({
|
||||
readSubminerProxyUrl: async () => (proxy ? 'http://127.0.0.1:8766' : null),
|
||||
fetch: async (_url: string, options: RequestInit) => {
|
||||
const { action, params } = JSON.parse(String(options.body));
|
||||
events.push(action);
|
||||
let result: unknown;
|
||||
switch (action) {
|
||||
case 'multi':
|
||||
result = [
|
||||
{ result: ['Default'], error: null },
|
||||
{ result: ['Basic'], error: null },
|
||||
{ result: ['Expression', 'ExpressionAudio'], error: null },
|
||||
];
|
||||
break;
|
||||
case 'canAddNotes':
|
||||
result = [true];
|
||||
break;
|
||||
case 'canAddNotesWithErrorDetail':
|
||||
result = [{ canAdd: true, error: null }];
|
||||
break;
|
||||
case 'getMediaFilesNames':
|
||||
result = stored ? [filename] : [];
|
||||
break;
|
||||
case 'storeMediaFile':
|
||||
stored = true;
|
||||
result = filename;
|
||||
break;
|
||||
case 'addNote':
|
||||
fields = { ...params.note.fields };
|
||||
initialAudio = fields.ExpressionAudio ?? '';
|
||||
if (initialAudio) assert.ok(stored, 'audio must exist before the note references it');
|
||||
result = 123;
|
||||
break;
|
||||
case 'notesInfo':
|
||||
result = [
|
||||
{
|
||||
noteId: 123,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, { value }]),
|
||||
),
|
||||
},
|
||||
];
|
||||
break;
|
||||
case 'updateNoteFields':
|
||||
Object.assign(fields, params.note.fields);
|
||||
result = null;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unexpected action: ${action}`);
|
||||
}
|
||||
return Response.json({ result, error: null });
|
||||
},
|
||||
});
|
||||
const service = createAnkiWorkerService({
|
||||
gateway,
|
||||
readOptions: async () => ({
|
||||
anki: {
|
||||
url: 'http://127.0.0.1:8766',
|
||||
apiKey: '',
|
||||
templates: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
deck: 'Default',
|
||||
model: 'Basic',
|
||||
tags: [],
|
||||
fields: {},
|
||||
duplicateScope: 'model',
|
||||
duplicateBehavior: 'prevent',
|
||||
captureScreenshot: false,
|
||||
fieldTemplates: {
|
||||
Expression: { value: '{expression}', overwriteMode: 'overwrite' },
|
||||
ExpressionAudio: { value: '{audio}', overwriteMode: 'overwrite' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
audioSources: [
|
||||
{ id: 'test', enabled: true, type: 'custom', url: 'https://example.test/{term}' },
|
||||
],
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import test from 'node:test';
|
||||
import { CHARACTER_DICTIONARY_TITLE_PREFIX } from '../src/core/services/tokenizer/character-dictionary-title';
|
||||
import { createAnkiGateway } from '../vendor/hachidori/extension/anki.js';
|
||||
|
||||
const bridge = readFileSync(
|
||||
new URL('../vendor/hachidori/extension/subminer-host.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function run(code: string) {
|
||||
runInNewContext(`${bridge}\n${code}`, {
|
||||
window: new EventTarget(),
|
||||
EventTarget,
|
||||
CustomEvent,
|
||||
assert,
|
||||
characterPrefix: CHARACTER_DICTIONARY_TITLE_PREFIX,
|
||||
KeyboardEvent: class {
|
||||
constructor(_type: string, options: KeyboardEventInit = {}) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('Hachidori publishes 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.attention(host, true);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'true');
|
||||
assert.equal(events.join(','), 'yomitan-popup-shown');
|
||||
SubMinerHachidori.lookup();
|
||||
SubMinerHachidori.attention(host, false);
|
||||
assert.equal(attributes.get('data-subminer-yomitan-popup-visible'), 'false');
|
||||
assert.equal(events.join(','), 'yomitan-popup-shown,subminer-yomitan-lookup,yomitan-popup-hidden');
|
||||
`);
|
||||
});
|
||||
|
||||
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,6 +27,14 @@ const REQUIRED_APP_FILES = [
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'hachidori/manifest.json',
|
||||
'hachidori/settings.html',
|
||||
'hachidori/subminer-host.js',
|
||||
'hachidori/vendor/hoshidicts.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded.wasm',
|
||||
'hachidori/vendor/hoshidicts-threaded-idbfs.wasm',
|
||||
'hachidori/LICENSE',
|
||||
'hachidori/SOURCE.json',
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
|
||||
+39
-10
@@ -5,6 +5,7 @@ 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');
|
||||
@@ -49,12 +50,16 @@ 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', 'stats']) {
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -63,22 +68,46 @@ async function smoke() {
|
||||
},
|
||||
});
|
||||
try {
|
||||
await win.loadFile(
|
||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
||||
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'))`,
|
||||
);
|
||||
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`);
|
||||
}
|
||||
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);
|
||||
assert.equal((await fetch(`${url}/api/stats/overview`)).status, 200);
|
||||
} finally {
|
||||
statsWindow.destroy();
|
||||
await statsServer.close();
|
||||
tracker.destroy();
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, both dictionary extensions, UI pages, stats HTTP, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1793,6 +1793,9 @@ export class AnkiIntegration {
|
||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||
): Promise<MediaTimingReviewDecision> {
|
||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||
log.debug(
|
||||
`[media-timing] review skipped: reviewTiming=${String(this.config.media?.reviewTiming)} callback=${this.mediaTimingReviewCallback ? 'set' : 'missing'}`,
|
||||
);
|
||||
return { action: 'use-original' };
|
||||
}
|
||||
return await this.mediaTimingReviewCallback({
|
||||
|
||||
@@ -441,6 +441,70 @@ 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('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -224,6 +224,31 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action === 'updateNoteFields') {
|
||||
const params = requestJson.params;
|
||||
if (
|
||||
!params ||
|
||||
typeof params !== 'object' ||
|
||||
!('subminerEnrich' in params) ||
|
||||
params.subminerEnrich !== true
|
||||
)
|
||||
return;
|
||||
const note = 'note' in params ? params.note : null;
|
||||
if (!note || typeof note !== 'object' || !('id' in note)) return;
|
||||
const response = this.tryParseJsonValue(responseBody);
|
||||
// AnkiConnect confirms updates with {result:null,error:null}; failures must never enrich.
|
||||
if (
|
||||
!response ||
|
||||
typeof response !== 'object' ||
|
||||
!('error' in response) ||
|
||||
response.error !== null ||
|
||||
!('result' in response) ||
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
if (action !== 'addNote' && action !== 'addNotes' && action !== 'multi') {
|
||||
return;
|
||||
}
|
||||
@@ -293,7 +318,7 @@ export class AnkiConnectProxyServer {
|
||||
typeof requestJson.action === 'string'
|
||||
? requestJson.action
|
||||
: String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
if (action !== 'addNote' && action !== 'updateNoteFields') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
@@ -301,12 +326,17 @@ export class AnkiConnectProxyServer {
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
if (
|
||||
!params ||
|
||||
(!Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds') &&
|
||||
!Object.prototype.hasOwnProperty.call(params, 'subminerEnrich'))
|
||||
) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
delete nextParams.subminerEnrich;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
@@ -455,7 +485,7 @@ export class AnkiConnectProxyServer {
|
||||
});
|
||||
}
|
||||
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
private enqueueNotes(noteIds: number[], recordAdded = true): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
@@ -472,7 +502,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
if (recordAdded) this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isStandaloneTexthookerCommand,
|
||||
parseArgs,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
shouldStartApp,
|
||||
} from './args';
|
||||
|
||||
@@ -475,3 +476,21 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(setup), true);
|
||||
assert.equal(shouldStartApp(setup), true);
|
||||
});
|
||||
|
||||
test('Hachidori settings starts the app without overlay or mpv prerequisites', () => {
|
||||
const args = parseArgs(['--hachidori']);
|
||||
assert.equal(args.hachidori, true);
|
||||
assert.equal(args.yomitan, false);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(args), true);
|
||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(args), false);
|
||||
assert.equal(isStandaloneTexthookerCommand(parseArgs(['--texthooker', '--hachidori'])), false);
|
||||
assert.equal(
|
||||
shouldRunDictionarySettingsOnlyStartup(parseArgs(['--hachidori', '--start'])),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldRunDictionarySettingsOnlyStartup(parseArgs(['--yomitan'])), true);
|
||||
});
|
||||
|
||||
+11
-1
@@ -13,6 +13,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
hachidori: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -285,6 +287,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--hachidori') args.hachidori = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
@@ -568,6 +571,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -647,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.hachidori &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
@@ -719,6 +724,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
@@ -769,8 +775,12 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
}
|
||||
|
||||
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
return args.yomitan && !args.hachidori && shouldRunDictionarySettingsOnlyStartup(args);
|
||||
}
|
||||
|
||||
export function shouldRunDictionarySettingsOnlyStartup(args: CliArgs): boolean {
|
||||
return (
|
||||
args.yomitan &&
|
||||
(args.yomitan || args.hachidori) &&
|
||||
!args.background &&
|
||||
!args.start &&
|
||||
!args.stop &&
|
||||
|
||||
@@ -24,6 +24,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||
assert.match(output, /--hachidori\s+Open Hachidori settings window/);
|
||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--yomitan Open Yomitan settings window
|
||||
--hachidori Open Hachidori settings window
|
||||
--settings Open SubMiner settings window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
@@ -22,6 +22,7 @@ export type {
|
||||
} from './definitions/shared';
|
||||
|
||||
const {
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
@@ -57,6 +58,7 @@ const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
|
||||
export const CORE_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'dictionaryBackend'
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
@@ -18,6 +19,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'notifications'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
dictionaryBackend: 'yomitan',
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
keybindings: [],
|
||||
websocket: {
|
||||
|
||||
@@ -81,6 +81,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
] as const;
|
||||
|
||||
return [
|
||||
{
|
||||
path: 'dictionaryBackend',
|
||||
kind: 'enum',
|
||||
enumValues: ['yomitan', 'hachidori'],
|
||||
defaultValue: defaultConfig.dictionaryBackend,
|
||||
description: 'Dictionary lookup backend. Restart SubMiner after changing this setting.',
|
||||
},
|
||||
{
|
||||
path: 'logging.level',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ConfigTemplateSection } from './shared';
|
||||
|
||||
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
{
|
||||
title: 'Dictionary Backend',
|
||||
description: ['Select the dictionary lookup backend: yomitan or hachidori.'],
|
||||
notes: [
|
||||
'Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.',
|
||||
],
|
||||
key: 'dictionaryBackend',
|
||||
},
|
||||
{
|
||||
title: 'Japanese Subtitle Generation',
|
||||
description: [
|
||||
|
||||
@@ -6,6 +6,17 @@ import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (src.dictionaryBackend === 'yomitan' || src.dictionaryBackend === 'hachidori') {
|
||||
resolved.dictionaryBackend = src.dictionaryBackend;
|
||||
} else if (src.dictionaryBackend !== undefined) {
|
||||
warn(
|
||||
'dictionaryBackend',
|
||||
src.dictionaryBackend,
|
||||
resolved.dictionaryBackend,
|
||||
"Expected 'yomitan' or 'hachidori'.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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');
|
||||
}
|
||||
});
|
||||
@@ -152,6 +152,7 @@ const SECTION_ORDER = new Map<string, number>(
|
||||
'Discord Rich Presence',
|
||||
'Jellyfin',
|
||||
'Texthooker',
|
||||
'Dictionary Lookup',
|
||||
'Yomitan',
|
||||
'Stats dashboard',
|
||||
'Startup warmups',
|
||||
@@ -234,8 +235,8 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Yomitan Popup',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Dictionary Popup',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup': 'Keep Primary Visible On Dictionary Popup',
|
||||
'subtitleStyle.primaryDefaultMode': 'Primary Subtitle Visibility Mode',
|
||||
'subtitleStyle.frequencyDictionary.mode': 'Frequency Mode',
|
||||
'subtitleStyle.css': 'CSS Declarations',
|
||||
@@ -276,7 +277,7 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'subtitleSidebar.css':
|
||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||
'subtitleStyle.primaryVisibleOnYomitanPopup':
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a Yomitan popup is open.',
|
||||
'When primary subtitles are in hover mode, keep the primary subtitle bar visible while a dictionary popup is open.',
|
||||
'websocket.enabled':
|
||||
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||
'discordPresence.updateIntervalMs':
|
||||
@@ -340,6 +341,9 @@ function humanizePath(path: string): string {
|
||||
}
|
||||
|
||||
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
|
||||
if (path === 'dictionaryBackend') {
|
||||
return { category: 'integrations', section: 'Dictionary Lookup' };
|
||||
}
|
||||
if (
|
||||
path === 'subtitleStyle.autoPauseVideoOnHover' ||
|
||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -133,6 +134,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
togglePrimarySubtitleBar: () => {
|
||||
calls.push('togglePrimarySubtitleBar');
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openHachidoriSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
openYomitanSettingsDelayed: (delayMs) => {
|
||||
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
|
||||
},
|
||||
@@ -673,6 +677,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
},
|
||||
ui: {
|
||||
openFirstRunSetup: () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
@@ -1133,3 +1138,14 @@ test('handleCliCommand reports async refresh-known-words errors to OSD', async (
|
||||
assert.ok(osd.some((value) => value.includes('Refresh known words failed: refresh boom')));
|
||||
assert.ok(calls.includes('stopApp'));
|
||||
});
|
||||
|
||||
for (const source of ['initial', 'second-instance'] as const) {
|
||||
test(`Hachidori settings command opens its own settings on ${source} invocation`, () => {
|
||||
const { deps, calls } = createDeps();
|
||||
handleCliCommand(makeArgs({ hachidori: true }), source, deps);
|
||||
assert.ok(calls.includes('openHachidoriSettingsDelayed:1000'));
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
assert.equal(calls.includes('initializeOverlayRuntime'), false);
|
||||
assert.equal(calls.includes('connectMpvClient'), false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openHachidoriSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
@@ -170,6 +171,7 @@ interface MiningCliRuntime {
|
||||
interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -275,6 +277,11 @@ export function createCliCommandDepsRuntime(
|
||||
options.ui.openYomitanSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openHachidoriSettingsDelayed: (delayMs) => {
|
||||
options.schedule(() => {
|
||||
options.ui.openHachidoriSettings();
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
@@ -425,6 +432,8 @@ export function handleCliCommand(
|
||||
deps.logDebug('Opened first-run setup flow.');
|
||||
} else if (args.yomitan) {
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.hachidori) {
|
||||
deps.openHachidoriSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveHachidoriExtensionPath } from './hachidori-extension';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
|
||||
test('Hachidori resolves development and packaged artifacts without falling back to Yomitan', () => {
|
||||
const options = { moduleDir: '/app/dist/core/services', resourcesPath: '/resources' };
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/app/build/hachidori/manifest.json',
|
||||
}),
|
||||
'/app/build/hachidori',
|
||||
);
|
||||
assert.equal(
|
||||
resolveHachidoriExtensionPath({
|
||||
...options,
|
||||
exists: (p) => p === '/resources/hachidori/manifest.json',
|
||||
}),
|
||||
'/resources/hachidori',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveHachidoriExtensionPath({ ...options, exists: () => false }),
|
||||
/build:hachidori/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Hachidori updates its own extension copy and preserves the Yomitan copy', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-backend-copy-'));
|
||||
try {
|
||||
const source = path.join(root, 'source');
|
||||
const profile = path.join(root, 'profile');
|
||||
fs.mkdirSync(source);
|
||||
fs.writeFileSync(path.join(source, 'manifest.json'), '{"name":"Hachidori","version":"1"}');
|
||||
fs.mkdirSync(path.join(profile, 'extensions/yomitan'), { recursive: true });
|
||||
fs.writeFileSync(path.join(profile, 'extensions/yomitan/marker'), 'preserved');
|
||||
const options = { extensionName: 'hachidori', platform: 'linux' } satisfies Parameters<
|
||||
typeof ensureExtensionCopyAsync
|
||||
>[2];
|
||||
const copy = await ensureExtensionCopyAsync(source, profile, options);
|
||||
assert.equal(copy.targetDir, path.join(profile, 'extensions/hachidori'));
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, false);
|
||||
fs.writeFileSync(path.join(source, 'bridge.js'), 'updated');
|
||||
assert.equal((await ensureExtensionCopyAsync(source, profile, options)).copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(profile, 'extensions/yomitan/marker'), 'utf8'),
|
||||
'preserved',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import electron from 'electron';
|
||||
import type { Extension, Session } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
import { HACHIDORI_SESSION_PARTITION } from './tokenizer/hachidori-parser-bridge';
|
||||
|
||||
export function getHachidoriSession(): Session {
|
||||
return electron.session.fromPartition(HACHIDORI_SESSION_PARTITION);
|
||||
}
|
||||
|
||||
export function resolveHachidoriExtensionPath(options: {
|
||||
moduleDir: string;
|
||||
resourcesPath: string;
|
||||
exists?: (candidate: string) => boolean;
|
||||
}): string {
|
||||
const candidates = [
|
||||
path.resolve(options.moduleDir, '../../../build/hachidori'),
|
||||
path.join(options.resourcesPath, 'hachidori'),
|
||||
];
|
||||
const found = candidates.find((candidate) =>
|
||||
(options.exists ?? existsSync)(path.join(candidate, 'manifest.json')),
|
||||
);
|
||||
if (!found) throw new Error('Hachidori is not bundled. Run bun run build:hachidori.');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Separate session keeps settings-only launches from injecting into the other backend's overlay. */
|
||||
export function createHachidoriExtensionRuntime(userDataPath: string) {
|
||||
let extension: Extension | null = null;
|
||||
let loading: Promise<Extension> | null = null;
|
||||
return {
|
||||
getSession: getHachidoriSession,
|
||||
ensureLoaded(): Promise<Extension> {
|
||||
if (extension) return Promise.resolve(extension);
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
const source = resolveHachidoriExtensionPath({
|
||||
moduleDir: __dirname,
|
||||
resourcesPath: process.resourcesPath,
|
||||
});
|
||||
const copy = await ensureExtensionCopyAsync(source, userDataPath, {
|
||||
extensionName: 'hachidori',
|
||||
});
|
||||
const session = getHachidoriSession();
|
||||
// Electron can reuse an old extension worker after its files change.
|
||||
// Drop worker registrations before loading, preserving dictionaries and settings.
|
||||
await session.clearStorageData({ storages: ['serviceworkers'] });
|
||||
extension = await session.extensions.loadExtension(copy.targetDir, {
|
||||
allowFileAccess: true,
|
||||
});
|
||||
return extension;
|
||||
})().finally(() => {
|
||||
loading = null;
|
||||
});
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
@@ -0,0 +1,446 @@
|
||||
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', enabled: true, revision: '1' },
|
||||
{ id: 'names', title: characterDictionary, enabled: true, revision: '1' },
|
||||
{
|
||||
id: 'frequency',
|
||||
title: 'Frequency',
|
||||
enabled: true,
|
||||
revision: '1',
|
||||
frequencyMode: 'rank-based',
|
||||
},
|
||||
];
|
||||
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_frequencies':
|
||||
return {
|
||||
ok: true,
|
||||
frequencies: [
|
||||
{
|
||||
term: '食べる',
|
||||
reading: 'たべる',
|
||||
hasReading: true,
|
||||
dictionary: 'Frequency',
|
||||
frequency: 42,
|
||||
displayValue: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
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 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 settings automation imports ZIP bytes and removes the matching dictionary ID', async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.run(
|
||||
"__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64('UEs=', 'characters.zip')",
|
||||
);
|
||||
await harness.run(
|
||||
`__subminerYomitanSettingsAutomation.deleteDictionary(${JSON.stringify(characterDictionary)})`,
|
||||
);
|
||||
const imported = harness.messages.find((message) => message.type === 'hd_import');
|
||||
assert.equal(imported?.fileName, 'characters.zip');
|
||||
assert.match(String(imported?.blobUrl), /^blob:/);
|
||||
const removed = harness.messages.find((message) => message.type === 'hd_remove');
|
||||
assert.equal(removed?.id, 'names');
|
||||
});
|
||||
|
||||
test('Hachidori waits for a loading engine and retries busy requests before answering', async () => {
|
||||
const harness = await createHarness();
|
||||
harness.setLoadingStatusReplies(2);
|
||||
harness.setBusyEngineReplies(1);
|
||||
const dictionaries = (await harness.invoke('getDictionaryInfo')) as Array<{ title: string }>;
|
||||
assert.equal(dictionaries.length, 3);
|
||||
const statusCalls = harness.messages.filter((message) => message.type === 'hd_status').length;
|
||||
assert.ok(statusCalls >= 3, `expected repeated status polls, saw ${statusCalls}`);
|
||||
const tokens = await requestYomitanScanTokens('食べた', harness.deps, { error: assert.fail });
|
||||
assert.equal(tokens?.[0]?.headword, '食べる');
|
||||
assert.equal(tokens?.[0]?.frequencyRank, 42);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
// 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 }) {
|
||||
let frequencies;
|
||||
try {
|
||||
({ frequencies } = await engine('hd_frequencies', { termReadingList }));
|
||||
} catch (error) {
|
||||
const { sharing } = await send('hd_sharing_status', {}, 'hachidori-sharing');
|
||||
if (!sharing?.client?.connected || !/unknown|unsupported|not supported/i.test(error.message)) throw error;
|
||||
// Older external hosts expose frequency data through term lookups only.
|
||||
frequencies = [];
|
||||
for (const { term, reading } of termReadingList) {
|
||||
const { results } = await engine('hd_lookup', { text: term, maxResults: 100 });
|
||||
for (const result of results) {
|
||||
if (result.term.expression !== term || (reading !== null && result.term.reading !== reading)) continue;
|
||||
for (const group of result.term.frequencies) {
|
||||
for (const value of group.frequencies) {
|
||||
frequencies.push({ term, reading: value.reading || null,
|
||||
hasReading: typeof value.reading === 'string' && value.reading.length > 0,
|
||||
dictionary: group.dictionary, frequency: value.value,
|
||||
displayValue: value.displayValue || null, displayValueParsed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return frequencies.filter(frequency => dictionaries.includes(frequency.dictionary));
|
||||
}
|
||||
// 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: [] };
|
||||
const status = await send('hd_anki_status', {}, 'hachidori-anki');
|
||||
const request = {
|
||||
...result, generation: lookup.generation, sentence: word, searchQuery: word,
|
||||
matchOffset: 0, documentTitle: 'SubMiner', popupSelectionText: '',
|
||||
configKey: status.configKey,
|
||||
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');
|
||||
};
|
||||
})();
|
||||
`;
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import type { AnkiConnectConfig } from '../../../types';
|
||||
import { buildHachidoriAnkiHints } from './hachidori-anki-settings';
|
||||
import {
|
||||
buildHachidoriSharingScript,
|
||||
parseHachidoriHostStatus,
|
||||
type HachidoriSharingRequest,
|
||||
} from '../../../shared/hachidori-sharing';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
HACHIDORI_PARSER_BRIDGE_SCRIPT,
|
||||
HACHIDORI_SESSION_PARTITION,
|
||||
} from './hachidori-parser-bridge';
|
||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||
import {
|
||||
buildYomitanScanCallScript,
|
||||
@@ -477,7 +488,7 @@ async function requestYomitanProfileMetadata(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -596,6 +607,18 @@ function logYomitanProfileDiagnostics(
|
||||
logger.info?.('Yomitan active profile dictionaries loaded.', details);
|
||||
}
|
||||
|
||||
function isHachidoriExtension(extension: Extension): boolean {
|
||||
return extension.name === 'Hachidori';
|
||||
}
|
||||
|
||||
// Without an explicit session, use the one the extension was loaded into:
|
||||
// Hachidori lives in its own partition, Yomitan in the default session.
|
||||
function resolveBackendSession(electron: typeof import('electron'), extension: Extension): Session {
|
||||
return isHachidoriExtension(extension)
|
||||
? electron.session.fromPartition(HACHIDORI_SESSION_PARTITION)
|
||||
: electron.session.defaultSession;
|
||||
}
|
||||
|
||||
async function ensureYomitanParserWindow(
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
@@ -606,19 +629,20 @@ async function ensureYomitanParserWindow(
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentWindow = deps.getYomitanParserWindow();
|
||||
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingInitPromise = deps.getYomitanParserInitPromise();
|
||||
if (existingInitPromise) {
|
||||
return existingInitPromise;
|
||||
}
|
||||
|
||||
const currentWindow = deps.getYomitanParserWindow();
|
||||
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession =
|
||||
deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const parserWindow = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
@@ -649,11 +673,15 @@ async function ensureYomitanParserWindow(
|
||||
});
|
||||
|
||||
try {
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
|
||||
const parserPage = isHachidoriExtension(yomitanExt) ? 'settings.html' : 'search.html';
|
||||
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/${parserPage}`);
|
||||
const readyPromise = deps.getYomitanParserReadyPromise();
|
||||
if (readyPromise) {
|
||||
await readyPromise;
|
||||
}
|
||||
if (isHachidoriExtension(yomitanExt)) {
|
||||
await parserWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
}
|
||||
// Eagerly install the scan runtime so the first subtitle line does not
|
||||
// pay the install round trip; failures fall back to the per-request
|
||||
// install-and-retry path.
|
||||
@@ -696,8 +724,8 @@ async function createYomitanExtensionWindow(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { BrowserWindow, session } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? session.defaultSession;
|
||||
const { BrowserWindow } = electron;
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? resolveBackendSession(electron, yomitanExt);
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 1200,
|
||||
@@ -740,6 +768,10 @@ async function invokeYomitanSettingsAutomation<T>(
|
||||
}
|
||||
|
||||
try {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (extension && isHachidoriExtension(extension)) {
|
||||
await settingsWindow.webContents.executeJavaScript(HACHIDORI_PARSER_BRIDGE_SCRIPT, true);
|
||||
}
|
||||
await settingsWindow.webContents.executeJavaScript(
|
||||
`
|
||||
(async () => {
|
||||
@@ -892,7 +924,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -922,7 +954,7 @@ export async function requestYomitanParseResults(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1079,7 +1111,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1122,7 +1154,7 @@ async function fetchYomitanTermFrequencies(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1206,7 +1238,17 @@ function cacheFrequencyEntriesForPairs(
|
||||
const key = makeTermReadingCacheKey(pair.term, pair.reading);
|
||||
const exactEntries = groupedByPair.get(key);
|
||||
const termEntries = groupedByTerm.get(pair.term) ?? [];
|
||||
frequencyCache.set(key, exactEntries ?? termEntries);
|
||||
// Untagged frequency rows apply to every reading. A term-only query must
|
||||
// retain all readings, rather than selecting only its untagged rows.
|
||||
const untaggedEntries = groupedByPair.get(makeTermReadingCacheKey(pair.term, null)) ?? [];
|
||||
frequencyCache.set(
|
||||
key,
|
||||
pair.reading === null
|
||||
? termEntries
|
||||
: exactEntries
|
||||
? [...exactEntries, ...untaggedEntries]
|
||||
: termEntries,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,6 +1382,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
options?: {
|
||||
forceOverride?: boolean;
|
||||
deck?: string;
|
||||
ankiConfig?: AnkiConnectConfig;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
@@ -1359,7 +1402,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1379,6 +1422,16 @@ 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,
|
||||
});
|
||||
}
|
||||
let previousManagedProxy = null;
|
||||
if (typeof globalThis.__subminerSetAnkiProxyUrl === 'function') {
|
||||
previousManagedProxy = await globalThis.__subminerSetAnkiProxyUrl(forceOverride ? targetServer : null);
|
||||
}
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
if (profiles.length === 0) {
|
||||
@@ -1405,7 +1458,8 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
let changed = false;
|
||||
if (currentServer !== targetServer) {
|
||||
const canReplaceCurrent =
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765" ||
|
||||
(typeof previousManagedProxy === 'string' && currentServer === previousManagedProxy);
|
||||
if (!canReplaceCurrent) {
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
}
|
||||
@@ -1455,6 +1509,12 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
|
||||
try {
|
||||
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
if (isObject(result) && result.pending === true) {
|
||||
logger.info?.(
|
||||
'Anki is unavailable; Hachidori field auto-population will retry when opened again',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const updated =
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
@@ -1564,7 +1624,7 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
(async () => {
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
@@ -1586,6 +1646,25 @@ function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string):
|
||||
`;
|
||||
}
|
||||
|
||||
export async function requestHachidoriSharing(
|
||||
request: HachidoriSharingRequest,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
) {
|
||||
const extension = deps.getYomitanExt();
|
||||
if (!extension || !isHachidoriExtension(extension)) throw new Error('Hachidori is not active.');
|
||||
const ready = await ensureYomitanParserWindow(deps, logger);
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!ready || !window || window.isDestroyed()) throw new Error('Hachidori is unavailable.');
|
||||
const reply: unknown = await window.webContents.executeJavaScript(
|
||||
buildHachidoriSharingScript(request),
|
||||
true,
|
||||
);
|
||||
const status = parseHachidoriHostStatus(reply);
|
||||
if (request.type !== 'hd_sharing_status') clearYomitanParserCachesForWindow(window);
|
||||
return status;
|
||||
}
|
||||
|
||||
async function invokeYomitanBackendAction<T>(
|
||||
action: string,
|
||||
params: unknown,
|
||||
|
||||
@@ -12,7 +12,7 @@ export type YomitanFrequencyMode = 'occurrence-based' | 'rank-based';
|
||||
|
||||
// Bump whenever the install script below changes so already-loaded parser
|
||||
// windows re-install the new scan runtime instead of running the stale one.
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 12;
|
||||
export const YOMITAN_SCAN_RUNTIME_VERSION = 13;
|
||||
export const YOMITAN_SCAN_RUNTIME_MISSING_SENTINEL = '__subminer-yomitan-scan-runtime-missing__';
|
||||
|
||||
export interface YomitanScanRequestParams {
|
||||
@@ -43,7 +43,7 @@ export const YOMITAN_SCAN_RUNTIME_INSTALL_SCRIPT = String.raw`
|
||||
}
|
||||
const invoke = (action, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||
(globalThis.__subminerDictionarySendMessage ?? chrome.runtime.sendMessage.bind(chrome.runtime))({ action, params }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ type ExtensionCopyResult = {
|
||||
|
||||
type ExtensionCopyOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
extensionName?: 'yomitan' | 'hachidori';
|
||||
};
|
||||
|
||||
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
|
||||
@@ -156,7 +157,7 @@ export function ensureExtensionCopy(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
|
||||
let shouldCopy = !fs.existsSync(targetDir);
|
||||
if (!shouldCopy) {
|
||||
@@ -182,7 +183,7 @@ export async function ensureExtensionCopyAsync(
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const targetDir = path.join(extensionsRoot, options?.extensionName ?? 'yomitan');
|
||||
const inFlightKey = path.resolve(targetDir);
|
||||
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
|
||||
@@ -7,6 +7,7 @@ const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = el
|
||||
const logger = createLogger('main:yomitan-settings');
|
||||
|
||||
export interface OpenYomitanSettingsWindowOptions {
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
yomitanExt: Extension | null;
|
||||
getExistingWindow: () => BrowserWindow | null;
|
||||
setWindow: (window: BrowserWindow | null) => void;
|
||||
@@ -23,6 +24,7 @@ type HyprlandSessionEnv = {
|
||||
export interface InstallYomitanSettingsCloseButtonOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: HyprlandSessionEnv;
|
||||
backend?: 'yomitan' | 'hachidori';
|
||||
}
|
||||
|
||||
export function shouldInstallYomitanSettingsCloseButton(
|
||||
@@ -53,7 +55,9 @@ export function buildYomitanSettingsWindowMenuTemplate(
|
||||
];
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
export function buildYomitanSettingsCloseButtonScript(
|
||||
backend: 'yomitan' | 'hachidori' = 'yomitan',
|
||||
): string {
|
||||
return `
|
||||
(() => {
|
||||
const buttonId = 'subminer-yomitan-settings-close';
|
||||
@@ -97,7 +101,7 @@ export function buildYomitanSettingsCloseButtonScript(): string {
|
||||
button.id = buttonId;
|
||||
button.type = 'button';
|
||||
button.title = 'Close';
|
||||
button.setAttribute('aria-label', 'Close Yomitan settings');
|
||||
button.setAttribute('aria-label', 'Close ${backend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings');
|
||||
button.textContent = '\\u00d7';
|
||||
button.addEventListener('click', () => {
|
||||
window.close();
|
||||
@@ -118,7 +122,7 @@ export function installYomitanSettingsCloseButton(
|
||||
return;
|
||||
}
|
||||
settingsWindow.webContents
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
|
||||
.executeJavaScript(buildYomitanSettingsCloseButtonScript(options.backend))
|
||||
.catch((error: Error) => {
|
||||
logger.warn('Failed to install Yomitan settings close button:', error.message);
|
||||
});
|
||||
@@ -184,7 +188,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
|
||||
|
||||
const settingsWindow = new ElectronBrowserWindow({
|
||||
title: 'Yomitan Settings',
|
||||
title: options.backend === 'hachidori' ? 'Hachidori Settings' : 'Yomitan Settings',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
@@ -228,7 +232,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
settingsWindow.webContents.on('did-finish-load', () => {
|
||||
logger.info('Settings page loaded successfully');
|
||||
installYomitanSettingsCloseButton(settingsWindow);
|
||||
installYomitanSettingsCloseButton(settingsWindow, { backend: options.backend });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
+162
-7
@@ -1,3 +1,4 @@
|
||||
import { requestHachidoriSharing } from './core/services/tokenizer/yomitan-parser-runtime';
|
||||
/*
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2026 sudacode
|
||||
@@ -32,6 +33,14 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from './shared/dictionary-external-link';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { readMpvInputBindings } from './main/runtime/mpv-input-bindings';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
@@ -906,14 +915,34 @@ const {
|
||||
appState,
|
||||
appLifecycleApp,
|
||||
} = bootServices;
|
||||
// Backend changes take effect on restart; windows and parser must share one session.
|
||||
const activeDictionaryBackend = configService.getConfig().dictionaryBackend;
|
||||
const hachidoriExtensionRuntime = createHachidoriExtensionRuntime(USER_DATA_PATH);
|
||||
let hachidoriSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanExtension: Extension | null = null;
|
||||
let inactiveYomitanSettingsWindow: BrowserWindow | null = null;
|
||||
let inactiveYomitanLoad: Promise<Extension | null> | null = null;
|
||||
let pendingSubtitleMiningContext: SubtitleMiningContext | null = null;
|
||||
const configSettingsFields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
|
||||
|
||||
ipcMain.handle(DICTIONARY_EXTERNAL_LINK_CHANNEL, async (event, value: unknown) => {
|
||||
if (
|
||||
activeDictionaryBackend !== 'hachidori' ||
|
||||
event.senderFrame !== event.sender.mainFrame ||
|
||||
!overlayManager.getOverlayWindows().some((window) => window.webContents === event.sender)
|
||||
) {
|
||||
throw new Error('Dictionary links are only available from the active overlay');
|
||||
}
|
||||
await shell.openExternal(parseDictionaryExternalUrl(value));
|
||||
});
|
||||
|
||||
function getOverlayForegroundSeparateWindows(): BrowserWindow[] {
|
||||
return [
|
||||
appState.configSettingsWindow,
|
||||
appState.syncUiWindow,
|
||||
appState.yomitanSettingsWindow,
|
||||
hachidoriSettingsWindow,
|
||||
inactiveYomitanSettingsWindow,
|
||||
appState.anilistSetupWindow,
|
||||
appState.jellyfinSetupWindow,
|
||||
appState.firstRunSetupWindow,
|
||||
@@ -1011,6 +1040,8 @@ const {
|
||||
} = statsServerRuntime;
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(hachidoriSettingsWindow);
|
||||
destroyYomitanSettingsWindow(inactiveYomitanSettingsWindow);
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
@@ -1455,6 +1486,15 @@ 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 () => {
|
||||
@@ -2277,6 +2317,11 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
if (appState.ankiIntegration) {
|
||||
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
|
||||
}
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanExt) {
|
||||
void syncYomitanDefaultProfileAnkiServer().catch((error: unknown) =>
|
||||
logger.error('Failed to auto-populate Hachidori Anki settings', error),
|
||||
);
|
||||
}
|
||||
},
|
||||
invalidateTokenizationCache: () => {
|
||||
subtitleProcessingController.invalidateTokenizationCache();
|
||||
@@ -3450,6 +3495,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return {
|
||||
configReady: snapshot.configReady,
|
||||
dictionaryCount: snapshot.dictionaryCount,
|
||||
dictionaryBackend: snapshot.dictionaryBackend,
|
||||
hachidoriHost: snapshot.hachidoriHost,
|
||||
canFinish: snapshot.canFinish,
|
||||
externalYomitanConfigured: snapshot.externalYomitanConfigured,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
@@ -3465,6 +3512,33 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||
if (
|
||||
submission.action === 'link-hachidori-host' ||
|
||||
submission.action === 'unlink-hachidori-host'
|
||||
) {
|
||||
try {
|
||||
if (activeDictionaryBackend !== 'hachidori')
|
||||
throw new Error('Select Hachidori and restart SubMiner before linking a host.');
|
||||
await ensureYomitanExtensionLoaded();
|
||||
const status = await requestHachidoriSharing(
|
||||
submission.action === 'link-hachidori-host'
|
||||
? { type: 'hd_sharing_client_link', address: submission.address }
|
||||
: { type: 'hd_sharing_client_unlink' },
|
||||
getYomitanParserRuntimeDeps(),
|
||||
logger,
|
||||
);
|
||||
firstRunSetupMessage =
|
||||
status.kind === 'local'
|
||||
? 'Using dictionaries installed in SubMiner.'
|
||||
: status.kind === 'connected'
|
||||
? `Linked to ${status.name}. Anki mining stays in SubMiner.`
|
||||
: status.message;
|
||||
} catch (error) {
|
||||
firstRunSetupMessage =
|
||||
error instanceof Error ? error.message : 'Could not update the dictionary host.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'remove-legacy-plugin') {
|
||||
const snapshot = await firstRunSetupService.removeLegacyMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
@@ -3505,8 +3579,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
firstRunSetupMessage = openDictionarySettings()
|
||||
? `Opened ${activeDictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan'} settings. Install dictionaries, then refresh status.`
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
@@ -5157,6 +5231,41 @@ function initializeOverlayRuntime(): void {
|
||||
}
|
||||
|
||||
function openYomitanSettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
if (configService.getConfig().yomitan.externalProfilePath.trim()) {
|
||||
logger.warn('Yomitan settings unavailable while using read-only external-profile mode.');
|
||||
return false;
|
||||
}
|
||||
inactiveYomitanLoad ??= inactiveYomitanExtension
|
||||
? Promise.resolve(inactiveYomitanExtension)
|
||||
: loadYomitanExtensionCore({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getYomitanParserWindow: () => null,
|
||||
setYomitanParserWindow: () => {},
|
||||
setYomitanParserReadyPromise: () => {},
|
||||
setYomitanParserInitPromise: () => {},
|
||||
setYomitanExtension: (extension) => {
|
||||
inactiveYomitanExtension = extension;
|
||||
},
|
||||
setYomitanSession: () => {},
|
||||
}).finally(() => {
|
||||
inactiveYomitanLoad = null;
|
||||
});
|
||||
void (
|
||||
inactiveYomitanExtension ? Promise.resolve(inactiveYomitanExtension) : inactiveYomitanLoad
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: () => inactiveYomitanSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
inactiveYomitanSettingsWindow = window;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Yomitan settings', error));
|
||||
return true;
|
||||
}
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||
logger.warn(
|
||||
@@ -5169,6 +5278,39 @@ function openYomitanSettings(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function openHachidoriSettings(): void {
|
||||
void (
|
||||
activeDictionaryBackend === 'hachidori'
|
||||
? ensureYomitanExtensionLoaded()
|
||||
: hachidoriExtensionRuntime.ensureLoaded()
|
||||
)
|
||||
.then((extension) =>
|
||||
openYomitanSettingsWindow({
|
||||
backend: 'hachidori',
|
||||
yomitanExt: extension,
|
||||
yomitanSession: getHachidoriSession(),
|
||||
getExistingWindow: () => hachidoriSettingsWindow,
|
||||
setWindow: (window) => {
|
||||
hachidoriSettingsWindow = window;
|
||||
},
|
||||
onWindowClosed: () => {
|
||||
if (activeDictionaryBackend === 'hachidori' && appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => logger.error('Failed to open Hachidori settings', error));
|
||||
}
|
||||
|
||||
function openDictionarySettings(): boolean {
|
||||
if (activeDictionaryBackend === 'hachidori') {
|
||||
openHachidoriSettings();
|
||||
return true;
|
||||
}
|
||||
return openYomitanSettings();
|
||||
}
|
||||
|
||||
const { exportLogsFromTray } = createLogExportTrayRuntime({
|
||||
flushMpvLog: () => flushMpvLog(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
@@ -5196,7 +5338,7 @@ const {
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
isDev,
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
}),
|
||||
@@ -5687,7 +5829,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
}
|
||||
},
|
||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openYomitanSettings: () => openDictionarySettings(),
|
||||
// Overlay lookups carry no cue context of their own; fall back to snapshotting the
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
@@ -6128,6 +6270,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6303,7 +6446,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
getYomitanSession: () =>
|
||||
activeDictionaryBackend === 'hachidori' ? getHachidoriSession() : appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||
@@ -6391,6 +6535,8 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
getDictionaryBackend: () => activeDictionaryBackend,
|
||||
openHachidoriSettings: () => openHachidoriSettings(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => configSettingsRuntime.openWindow(),
|
||||
openSyncUiWindow: () => openSyncUiWindowHandler(),
|
||||
@@ -6435,12 +6581,21 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
|
||||
});
|
||||
const yomitanProfilePolicy = createYomitanProfilePolicy({
|
||||
externalProfilePath: configService.getConfig().yomitan.externalProfilePath,
|
||||
externalProfilePath:
|
||||
activeDictionaryBackend === 'yomitan'
|
||||
? configService.getConfig().yomitan.externalProfilePath
|
||||
: '',
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
|
||||
const yomitanExtensionRuntime = createYomitanExtensionRuntime({
|
||||
loadYomitanExtensionCore,
|
||||
loadYomitanExtensionCore: async (deps) => {
|
||||
if (activeDictionaryBackend === 'yomitan') return loadYomitanExtensionCore(deps);
|
||||
const extension = await hachidoriExtensionRuntime.ensureLoaded();
|
||||
deps.setYomitanExtension(extension);
|
||||
deps.setYomitanSession(getHachidoriSession());
|
||||
return extension;
|
||||
},
|
||||
userDataPath: USER_DATA_PATH,
|
||||
externalProfilePath: configuredExternalYomitanProfilePath,
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -135,6 +136,7 @@ function createCliCommandDepsFromContext(
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
openHachidoriSettings: context.openHachidoriSettings,
|
||||
openConfigSettingsWindow: context.openConfigSettingsWindow,
|
||||
openSyncUiWindow: context.openSyncUiWindow,
|
||||
cycleSecondarySubMode: context.cycleSecondarySubMode,
|
||||
|
||||
@@ -217,6 +217,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
openHachidoriSettings: CliCommandDepsRuntimeOptions['ui']['openHachidoriSettings'];
|
||||
openConfigSettingsWindow: CliCommandDepsRuntimeOptions['ui']['openConfigSettingsWindow'];
|
||||
openSyncUiWindow: CliCommandDepsRuntimeOptions['ui']['openSyncUiWindow'];
|
||||
cycleSecondarySubMode: CliCommandDepsRuntimeOptions['ui']['cycleSecondarySubMode'];
|
||||
@@ -430,6 +431,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
openHachidoriSettings: params.ui.openHachidoriSettings,
|
||||
openConfigSettingsWindow: params.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: params.ui.openSyncUiWindow,
|
||||
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,
|
||||
|
||||
@@ -18,6 +18,7 @@ type MockWindow = {
|
||||
contentReady: boolean;
|
||||
documentLoaded: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
stopLoadingCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
@@ -37,6 +38,7 @@ function createMockWindow(): MockWindow & {
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitDidStopLoading: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
@@ -45,7 +47,7 @@ function createMockWindow(): MockWindow & {
|
||||
getURL: () => string;
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
isFocused: () => boolean;
|
||||
once: (event: 'did-finish-load', cb: () => void) => void;
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
} {
|
||||
@@ -65,6 +67,7 @@ function createMockWindow(): MockWindow & {
|
||||
contentReady: true,
|
||||
documentLoaded: true,
|
||||
loadCallbacks: [],
|
||||
stopLoadingCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
@@ -112,6 +115,13 @@ function createMockWindow(): MockWindow & {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitDidStopLoading: () => {
|
||||
state.loading = false;
|
||||
const callbacks = state.stopLoadingCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
@@ -133,7 +143,11 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
focused: false,
|
||||
isFocused: () => state.webContentsFocused,
|
||||
once: (_event: 'did-finish-load', cb: () => void) => {
|
||||
once: (event: 'did-finish-load' | 'did-stop-loading', cb: () => void) => {
|
||||
if (event === 'did-stop-loading') {
|
||||
state.stopLoadingCallbacks.push(cb);
|
||||
return;
|
||||
}
|
||||
state.loadCallbacks.push(cb);
|
||||
},
|
||||
focus: () => {
|
||||
@@ -892,6 +906,44 @@ test('sendToActiveOverlayWindow delivers on first modal load without waiting for
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers to a modal window created for the send', () => {
|
||||
// Electron keeps isLoading() true inside did-finish-load, and ready-to-show can fire
|
||||
// before it; only did-stop-loading sees the settled state.
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
window.documentLoaded = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'media-timing-review:open',
|
||||
{ reviewId: 'r1' },
|
||||
{
|
||||
restoreOnModalClose: 'media-timing-review',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.documentLoaded = true;
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidStopLoading();
|
||||
assert.deepEqual(window.sent, [['media-timing-review:open', { reviewId: 'r1' }]]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
|
||||
@@ -206,6 +206,7 @@ export function createOverlayModalRuntimeService(
|
||||
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
|
||||
modalWindowPrimeListenersRegistered.add(modalWindow);
|
||||
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.webContents.once('did-stop-loading', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
|
||||
}
|
||||
return true;
|
||||
@@ -281,7 +282,11 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||
// renderer can safely receive IPC as soon as its document has finished loading.
|
||||
// Electron still reports isLoading() inside did-finish-load (and ready-to-show can
|
||||
// fire even earlier), so a window created for this send would drop the message
|
||||
// without the did-stop-loading pass that follows once the load state settles.
|
||||
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.webContents.once('did-stop-loading', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
|
||||
deliver(() => isWindowLoadedForIpc(window));
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -107,6 +108,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -103,6 +103,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('open-config-settings'),
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -64,6 +64,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -144,6 +145,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
openHachidoriSettings: () => deps.openHachidoriSettings(),
|
||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||
openSyncUiWindow: () => deps.openSyncUiWindow(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -56,6 +56,7 @@ function createDeps() {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -52,6 +52,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceContext['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openHachidoriSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -134,6 +135,7 @@ export function createCliCommandContext(
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
runUpdateCommand: async () => {},
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -6,6 +6,11 @@ import path from 'node:path';
|
||||
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../shared/setup-state';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
|
||||
@@ -30,6 +35,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
hachidori: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
@@ -762,3 +768,115 @@ test('setup service reports failed legacy mpv plugin trash paths', async () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching to Hachidori requires its own dictionaries and persists backend readiness', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
let dictionaryCount = 0;
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => dictionaryCount,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(initial.canFinish, false);
|
||||
assert.equal(initial.externalYomitanConfigured, false);
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
dictionaryCount = 1;
|
||||
const completed = await hachidori.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.state.dictionaryBackend, 'hachidori');
|
||||
assert.equal(completed.state.lastSeenYomitanDictionaryCount, 1);
|
||||
assert.equal(hachidori.isSetupCompleted(), true);
|
||||
assert.deepEqual(completed.state.completedDictionaryBackends, ['yomitan', 'hachidori']);
|
||||
|
||||
// Switching back never repeats setup for a backend that already finished.
|
||||
const yomitanAgain = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const restored = await yomitanAgain.ensureSetupStateInitialized();
|
||||
assert.equal(restored.state.status, 'completed');
|
||||
assert.equal(restored.state.dictionaryBackend, 'yomitan');
|
||||
assert.equal(yomitanAgain.isSetupCompleted(), true);
|
||||
assert.equal(readSetupState(getSetupStatePath(configDir))?.dictionaryBackend, 'yomitan');
|
||||
assert.deepEqual(restored.state.completedDictionaryBackends, ['hachidori', 'yomitan']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy completed Yomitan state file survives a first Hachidori run', async () => {
|
||||
await withTempDir(async (configDir) => {
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
fs.writeFileSync(
|
||||
getSetupStatePath(configDir),
|
||||
JSON.stringify({ ...createDefaultSetupState(), status: 'completed', completedAt: 'x' }),
|
||||
);
|
||||
const hachidori = createFirstRunSetupService({
|
||||
configDir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
const initial = await hachidori.ensureSetupStateInitialized();
|
||||
assert.equal(initial.state.status, 'incomplete');
|
||||
const stored = readSetupState(getSetupStatePath(configDir));
|
||||
assert.equal(stored?.dictionaryBackend, 'hachidori');
|
||||
assert.deepEqual(stored?.completedDictionaryBackends, ['yomitan']);
|
||||
const yomitan = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
assert.equal((await yomitan.ensureSetupStateInitialized()).state.status, 'completed');
|
||||
});
|
||||
});
|
||||
|
||||
test('Hachidori setup gates on the linked host instead of local dictionaries', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
|
||||
let host: import('../../shared/hachidori-sharing').HachidoriHostStatus = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 2,
|
||||
};
|
||||
const service = createFirstRunSetupService({
|
||||
configDir: dir,
|
||||
getDictionaryBackend: () => 'hachidori',
|
||||
getHachidoriHostStatus: async () => host,
|
||||
getYomitanDictionaryCount: async () => 7,
|
||||
detectPluginInstalled: () => false,
|
||||
});
|
||||
let snapshot = await service.getSetupStatus();
|
||||
assert.equal(snapshot.dictionaryCount, 2);
|
||||
assert.equal(snapshot.canFinish, true);
|
||||
assert.equal((await service.markSetupCompleted()).state.status, 'completed');
|
||||
host = { kind: 'disconnected', address: 'ws://127.0.0.1:8771/link', message: 'Host offline' };
|
||||
snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.canFinish, false);
|
||||
assert.equal(snapshot.dictionaryCount, 0);
|
||||
assert.equal(snapshot.state.status, 'incomplete');
|
||||
assert.notEqual((await service.markSetupCompleted()).state.status, 'completed');
|
||||
host = {
|
||||
kind: 'connected',
|
||||
address: 'ws://127.0.0.1:8771/link',
|
||||
name: 'Docker',
|
||||
dictionaryCount: 0,
|
||||
};
|
||||
assert.equal((await service.getSetupStatus()).canFinish, false);
|
||||
host = { kind: 'local' };
|
||||
assert.equal((await service.getSetupStatus()).dictionaryCount, 7);
|
||||
assert.equal((await service.getSetupStatus()).canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStateDictionaryBackend,
|
||||
hasCompletedSetupForBackend,
|
||||
getSetupStatePath,
|
||||
isSetupCompleted,
|
||||
readSetupState,
|
||||
@@ -11,6 +14,7 @@ import {
|
||||
type SetupState,
|
||||
} from '../../shared/setup-state';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import type {
|
||||
InstalledFirstRunPluginCandidate,
|
||||
LegacyMpvPluginRemovalResult,
|
||||
@@ -28,6 +32,8 @@ export interface SetupWindowsMpvShortcutSnapshot {
|
||||
}
|
||||
|
||||
export interface SetupStatusSnapshot {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -72,6 +78,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.launchMpv ||
|
||||
args.yomitan ||
|
||||
args.hachidori ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -205,6 +212,8 @@ function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnap
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
@@ -213,8 +222,18 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
if (!snapshot.configReady) {
|
||||
return 'Create or provide the config file before finishing setup.';
|
||||
}
|
||||
if (
|
||||
snapshot.hachidoriHost?.kind === 'disconnected' ||
|
||||
snapshot.hachidoriHost?.kind === 'unavailable'
|
||||
) {
|
||||
return snapshot.hachidoriHost.message;
|
||||
}
|
||||
if (snapshot.hachidoriHost?.kind === 'connected' && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one dictionary on the linked Hachidori host, then refresh status.';
|
||||
}
|
||||
if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) {
|
||||
return 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
const name = snapshot.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
return `Install at least one ${name} dictionary before finishing setup.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -222,15 +241,38 @@ export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
async function resolveYomitanSetupStatus(deps: {
|
||||
configFilePaths: { jsoncPath: string; jsonPath: string };
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
}): Promise<{
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
externalYomitanConfigured: boolean;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
}> {
|
||||
const configReady =
|
||||
fs.existsSync(deps.configFilePaths.jsoncPath) || fs.existsSync(deps.configFilePaths.jsonPath);
|
||||
const externalYomitanConfigured = deps.isExternalYomitanConfigured?.() ?? false;
|
||||
const externalYomitanConfigured =
|
||||
deps.getDictionaryBackend?.() !== 'hachidori' &&
|
||||
(deps.isExternalYomitanConfigured?.() ?? false);
|
||||
|
||||
const hachidoriHost =
|
||||
deps.getDictionaryBackend?.() === 'hachidori'
|
||||
? await deps.getHachidoriHostStatus?.().catch(
|
||||
(error: unknown): HachidoriHostStatus => ({
|
||||
kind: 'unavailable',
|
||||
message: error instanceof Error ? error.message : 'Hachidori is unavailable.',
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
if (hachidoriHost && hachidoriHost.kind !== 'local') {
|
||||
return {
|
||||
configReady,
|
||||
externalYomitanConfigured: false,
|
||||
hachidoriHost,
|
||||
dictionaryCount: hachidoriHost.kind === 'connected' ? hachidoriHost.dictionaryCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (configReady && externalYomitanConfigured) {
|
||||
return {
|
||||
@@ -242,6 +284,7 @@ async function resolveYomitanSetupStatus(deps: {
|
||||
|
||||
return {
|
||||
configReady,
|
||||
hachidoriHost,
|
||||
dictionaryCount: await deps.getYomitanDictionaryCount(),
|
||||
externalYomitanConfigured,
|
||||
};
|
||||
@@ -251,6 +294,8 @@ export function createFirstRunSetupService(deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
configDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
getDictionaryBackend?: () => DictionaryBackend;
|
||||
getHachidoriHostStatus?: () => Promise<HachidoriHostStatus>;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
detectLegacyMpvPluginCandidates?: () =>
|
||||
@@ -283,8 +328,44 @@ export function createFirstRunSetupService(deps: {
|
||||
const isWindows = (deps.platform ?? process.platform) === 'win32';
|
||||
let completed = false;
|
||||
|
||||
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
const getDictionaryBackend = () => deps.getDictionaryBackend?.() ?? 'yomitan';
|
||||
const readStoredState = (): SetupState =>
|
||||
readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
// The file records one backend's status at a time. Project it onto the active
|
||||
// backend: a backend that finished before stays completed, any other stays
|
||||
// incomplete until its own dictionaries are ready.
|
||||
const projectState = (stored: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
if (getSetupStateDictionaryBackend(stored) === backend) return stored;
|
||||
const finishedBefore = hasCompletedSetupForBackend(stored, backend);
|
||||
// Legacy files carry their completion only as the recorded status; keep it.
|
||||
const storedBackend = getSetupStateDictionaryBackend(stored);
|
||||
const completedDictionaryBackends = [
|
||||
...new Set([
|
||||
...(stored.completedDictionaryBackends ?? []),
|
||||
...(stored.status === 'completed' ? [storedBackend] : []),
|
||||
]),
|
||||
];
|
||||
return {
|
||||
...stored,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends,
|
||||
status: finishedBefore ? 'completed' : 'incomplete',
|
||||
completedAt: finishedBefore ? stored.completedAt : null,
|
||||
completionSource: finishedBefore ? (stored.completionSource ?? 'user') : null,
|
||||
yomitanSetupMode: finishedBefore ? 'internal' : null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
};
|
||||
};
|
||||
const readState = (): SetupState => projectState(readStoredState());
|
||||
const writeState = (state: SetupState): SetupState => {
|
||||
const backend = getDictionaryBackend();
|
||||
const others = (state.completedDictionaryBackends ?? []).filter((entry) => entry !== backend);
|
||||
state = {
|
||||
...state,
|
||||
dictionaryBackend: backend,
|
||||
completedDictionaryBackends: state.status === 'completed' ? [...others, backend] : others,
|
||||
};
|
||||
writeSetupState(setupStatePath, state);
|
||||
completed = state.status === 'completed';
|
||||
deps.onStateChanged?.(state);
|
||||
@@ -292,10 +373,12 @@ export function createFirstRunSetupService(deps: {
|
||||
};
|
||||
|
||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured, hachidoriHost } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
@@ -314,6 +397,8 @@ export function createFirstRunSetupService(deps: {
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
return {
|
||||
dictionaryBackend: getDictionaryBackend(),
|
||||
hachidoriHost,
|
||||
configReady,
|
||||
dictionaryCount,
|
||||
canFinish: isYomitanSetupSatisfied({
|
||||
@@ -353,11 +438,19 @@ export function createFirstRunSetupService(deps: {
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: async () => {
|
||||
const state = readState();
|
||||
const stored = readStoredState();
|
||||
// Persist the active backend stamp so the launcher can tell which backend
|
||||
// the running app gates playback on.
|
||||
const state =
|
||||
getSetupStateDictionaryBackend(stored) === getDictionaryBackend()
|
||||
? stored
|
||||
: writeState(projectState(stored));
|
||||
const { configReady, dictionaryCount, externalYomitanConfigured } =
|
||||
await resolveYomitanSetupStatus({
|
||||
configFilePaths,
|
||||
getYomitanDictionaryCount: deps.getYomitanDictionaryCount,
|
||||
getDictionaryBackend,
|
||||
getHachidoriHostStatus: deps.getHachidoriHostStatus,
|
||||
isExternalYomitanConfigured: deps.isExternalYomitanConfigured,
|
||||
});
|
||||
const canFinish = isYomitanSetupSatisfied({
|
||||
|
||||
@@ -779,3 +779,51 @@ test('closing completed first-run setup quits app when completion policy allows
|
||||
|
||||
assert.deepEqual(calls, ['set', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
test('Hachidori setup names the active dictionary backend', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
dictionaryBackend: 'hachidori',
|
||||
configReady: true,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
assert.match(html, /Hachidori dictionaries/);
|
||||
assert.match(html, /Open Hachidori Settings/);
|
||||
assert.match(html, /Install at least one Hachidori dictionary/);
|
||||
assert.doesNotMatch(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Link host/);
|
||||
assert.match(html, /<details class="external-host">/);
|
||||
assert.match(html, /Use an external dictionary host/);
|
||||
assert.match(html, /keep the browser and its sharing relay running/);
|
||||
assert.match(html, /You can close its browser management page/);
|
||||
});
|
||||
|
||||
test('setup parses link and unlink actions without losing the host address', () => {
|
||||
const address = 'ws://127.0.0.1:38771/link';
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl(
|
||||
'subminer://first-run-setup?action=link-hachidori-host&address=' +
|
||||
encodeURIComponent(address),
|
||||
),
|
||||
{ action: 'link-hachidori-host', address },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=unlink-hachidori-host'),
|
||||
{ action: 'unlink-hachidori-host' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HachidoriHostStatus } from '../../shared/hachidori-sharing';
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
@@ -18,25 +20,31 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
export type FirstRunSetupSubmission =
|
||||
| { action: 'configure-mpv-executable-path'; mpvExecutablePath: string }
|
||||
| {
|
||||
action: 'configure-windows-mpv-shortcuts';
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}
|
||||
| { action: 'link-hachidori-host'; address: string }
|
||||
| {
|
||||
action:
|
||||
| 'unlink-hachidori-host'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'open-config-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
};
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
mpvExecutablePath?: string;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
export type FirstRunSetupAction = FirstRunSetupSubmission['action'];
|
||||
|
||||
export interface FirstRunSetupHtmlModel {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
hachidoriHost?: HachidoriHostStatus;
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
@@ -249,9 +257,47 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const yomitanMeta = model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const dictionaryName = model.dictionaryBackend === 'hachidori' ? 'Hachidori' : 'Yomitan';
|
||||
const host = model.hachidoriHost;
|
||||
const linked = host?.kind === 'connected' || host?.kind === 'disconnected';
|
||||
const hostAddress = linked ? host.address : '';
|
||||
const hostCard =
|
||||
model.dictionaryBackend === 'hachidori'
|
||||
? `
|
||||
<div class="card block">
|
||||
<strong>Dictionary source</strong>
|
||||
<p class="meta">Import dictionaries through Hachidori Settings to keep them in SubMiner, or connect to an existing library below.</p>
|
||||
<details class="external-host"${linked ? ' open' : ''}>
|
||||
<summary>Use an external dictionary host</summary>
|
||||
<p class="meta">Connect to Hachidori in another app, browser, or Docker container to use the dictionaries already installed there. You won't need to import a second copy into SubMiner.</p>
|
||||
<p class="meta">Dictionaries and their settings are shared. You still mine cards in SubMiner, using its own Anki settings, audio, and screenshots.</p>
|
||||
<ul class="meta host-requirements">
|
||||
<li>Browser: keep the browser and its sharing relay running. If the relay runs through Anki, keep Anki open too.</li>
|
||||
<li>Desktop app: keep the app sharing your dictionaries and any required relay running.</li>
|
||||
<li>Docker: keep the dictionary container running. You can close its browser management page.</li>
|
||||
</ul>
|
||||
<p class="meta">If that app or container stops or loses its connection, dictionary lookups will be unavailable until it reconnects.</p>
|
||||
<form class="path-form" onsubmit="event.preventDefault(); const address = document.getElementById('hachidori-host-address').value; window.location.href='subminer://first-run-setup?action=link-hachidori-host&address='+encodeURIComponent(address)">
|
||||
<label for="hachidori-host-address">Host address</label>
|
||||
<input id="hachidori-host-address" type="text" value="${escapeHtml(hostAddress)}" placeholder="127.0.0.1:8771 or ws://host:8771/link" required />
|
||||
<div class="meta">Use the address from Hachidori's Sharing settings or your container's WebSocket sharing address, rather than its management page URL.</div>
|
||||
<div class="inline-actions">
|
||||
<button type="submit">${linked ? 'Change host' : 'Link host'}</button>
|
||||
${linked ? `<button type="button" class="ghost" onclick="window.location.href='subminer://first-run-setup?action=unlink-hachidori-host'">Unlink and use local dictionaries</button>` : ''}
|
||||
</div>
|
||||
</form>
|
||||
${host?.kind === 'disconnected' || host?.kind === 'unavailable' ? `<p class="meta">${escapeHtml(host.message)}</p>` : ''}
|
||||
</details>
|
||||
</div>`
|
||||
: '';
|
||||
const yomitanMeta =
|
||||
host?.kind === 'connected'
|
||||
? `${host.dictionaryCount} ${host.dictionaryCount === 1 ? 'dictionary' : 'dictionaries'} from ${host.name} at ${host.address}`
|
||||
: host?.kind === 'disconnected'
|
||||
? `Host unavailable: ${host.address}`
|
||||
: model.externalYomitanConfigured
|
||||
? 'External profile configured. SubMiner is reusing that Yomitan profile for this setup run.'
|
||||
: `${model.dictionaryCount} installed`;
|
||||
const yomitanBadgeLabel = model.externalYomitanConfigured
|
||||
? 'External'
|
||||
: model.dictionaryCount >= 1
|
||||
@@ -268,8 +314,8 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.canFinish
|
||||
? model.externalYomitanConfigured
|
||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
|
||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||
: `Finish stays unlocked once ${dictionaryName} reports at least one installed dictionary.`
|
||||
: `Finish stays locked until ${dictionaryName} reports at least one installed dictionary.`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -338,6 +384,21 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.external-host summary {
|
||||
cursor: pointer;
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
.external-host[open] summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.host-requirements {
|
||||
padding-left: 20px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.host-requirements li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.shortcut-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -466,17 +527,18 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
<div class="card">
|
||||
<div>
|
||||
<strong>Yomitan dictionaries</strong>
|
||||
<strong>${dictionaryName} dictionaries</strong>
|
||||
<div class="meta">${escapeHtml(yomitanMeta)}</div>
|
||||
</div>
|
||||
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
|
||||
</div>
|
||||
${hostCard}
|
||||
${mpvExecutablePathCard}
|
||||
${windowsShortcutCard}
|
||||
${renderCommandLineLauncherSection(model.commandLineLauncher)}
|
||||
${legacyPluginCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open ${dictionaryName} Settings</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
|
||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||
@@ -495,6 +557,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
const parsed = new URL(rawUrl);
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'link-hachidori-host' &&
|
||||
action !== 'unlink-hachidori-host' &&
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'remove-legacy-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
@@ -507,6 +571,9 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'link-hachidori-host') {
|
||||
return { action, address: parsed.searchParams.get('address')?.trim() ?? '' };
|
||||
}
|
||||
if (action === 'configure-mpv-executable-path') {
|
||||
return {
|
||||
action,
|
||||
|
||||
@@ -37,3 +37,10 @@ test('managed background playback handles initial args before deferred overlay w
|
||||
);
|
||||
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
|
||||
});
|
||||
|
||||
for (const flag of ['--yomitan', '--hachidori']) {
|
||||
test(`${flag} settings startup skips heavy startup`, () => {
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag])).shouldSkipHeavyStartup, true);
|
||||
assert.equal(getStartupModeFlags(parseArgs([flag, '--start'])).shouldSkipHeavyStartup, false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
import {
|
||||
isHeadlessInitialCommand,
|
||||
isStandaloneTexthookerCommand,
|
||||
shouldRunYomitanOnlyStartup,
|
||||
shouldRunDictionarySettingsOnlyStartup,
|
||||
} from '../../cli/args';
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
@@ -20,7 +20,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunYomitanOnlyStartup(initialArgs) ||
|
||||
(shouldRunDictionarySettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
|
||||
@@ -172,6 +172,7 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
|
||||
@@ -71,6 +71,8 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
@@ -127,6 +129,8 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
||||
showFirstRunSetup: () => false,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('configuration'),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createResolveTrayIconPathHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -46,6 +48,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -67,6 +71,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -107,6 +113,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow(true);
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
dictionaryBackend: deps.getDictionaryBackend(),
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||
openSyncUiWindow: () => calls.push('sync-ui'),
|
||||
@@ -58,6 +60,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openConfigSettings: () => calls.push('open-configuration'),
|
||||
openSyncUi: () => {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function createBuildResolveTrayIconPathMainDepsHandler(deps: {
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
@@ -36,6 +38,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -57,6 +61,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
getDictionaryBackend: () => DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
@@ -82,6 +88,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
getDictionaryBackend: deps.getDictionaryBackend,
|
||||
openHachidoriSettings: deps.openHachidoriSettings,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
openSyncUiWindow: deps.openSyncUiWindow,
|
||||
|
||||
@@ -31,6 +31,8 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
getDictionaryBackend: () => 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
|
||||
@@ -26,85 +26,96 @@ test('resolve tray icon returns null when no asset exists', () => {
|
||||
assert.equal(path, null);
|
||||
});
|
||||
|
||||
test('tray menu template contains expected entries and handlers', () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
for (const dictionaryBackend of ['yomitan', 'hachidori'] as const) {
|
||||
const settingsLabel =
|
||||
dictionaryBackend === 'hachidori' ? 'Open Hachidori Settings' : 'Open Yomitan Settings';
|
||||
test(`tray menu shows only ${dictionaryBackend} settings and dispatches its handler`, () => {
|
||||
const calls: string[] = [];
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openChangelog: () => calls.push('changelog'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
dictionaryBackend,
|
||||
openHachidoriSettings: () => calls.push('hachidori'),
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openConfigSettings: () => calls.push('configuration'),
|
||||
openSyncUi: () => calls.push('sync-ui'),
|
||||
exportLogs: () => calls.push('export-logs'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
showJellyfinDiscovery: true,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: (checked) => calls.push(`jellyfin-discovery:${checked}`),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
settingsLabel,
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
|
||||
entryFor(settingsLabel).click?.();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], dictionaryBackend);
|
||||
calls.length = 0;
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
// Resolve by label, not index: adding a menu entry should not force every
|
||||
// later assertion in this test to be renumbered.
|
||||
const entryFor = (label: string) => {
|
||||
const entry = template.find((candidate) => candidate.label === label);
|
||||
assert.ok(entry, `expected a "${label}" tray entry`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
template.map((entry) => entry.label ?? `<${entry.type}>`),
|
||||
[
|
||||
'Open Help',
|
||||
'View Changelog',
|
||||
'Open Texthooker',
|
||||
'Complete Setup',
|
||||
'Open SubMiner Setup',
|
||||
'Open Yomitan Settings',
|
||||
'Open SubMiner Settings',
|
||||
'Sync Stats && History',
|
||||
'Export Logs',
|
||||
'Configure Jellyfin',
|
||||
'Jellyfin Discovery',
|
||||
'Configure AniList',
|
||||
'Check for Updates',
|
||||
'<separator>',
|
||||
'Quit',
|
||||
],
|
||||
);
|
||||
|
||||
const discovery = entryFor('Jellyfin Discovery');
|
||||
assert.equal(discovery.type, 'checkbox');
|
||||
assert.equal(discovery.checked, false);
|
||||
discovery.click?.({ checked: true });
|
||||
|
||||
entryFor('Open Help').click?.();
|
||||
entryFor('View Changelog').click?.();
|
||||
entryFor('Open Texthooker').click?.();
|
||||
entryFor('Sync Stats && History').click?.();
|
||||
entryFor('Export Logs').click?.();
|
||||
entryFor('Check for Updates').click?.();
|
||||
calls.push(template.some((entry) => entry.type === 'separator') ? 'separator' : 'bad');
|
||||
entryFor('Quit').click?.();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery:true',
|
||||
'help',
|
||||
'changelog',
|
||||
'texthooker',
|
||||
'sync-ui',
|
||||
'export-logs',
|
||||
'updates',
|
||||
'separator',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
@@ -116,6 +127,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -146,6 +159,8 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -174,6 +189,8 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
@@ -203,6 +220,8 @@ test('tray menu template renders a visible linux discovery check mark when activ
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
dictionaryBackend: 'yomitan',
|
||||
openHachidoriSettings: () => {},
|
||||
openYomitanSettings: () => undefined,
|
||||
openConfigSettings: () => undefined,
|
||||
openSyncUi: () => undefined,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DictionaryBackend } from '../../types/config';
|
||||
|
||||
export function resolveTrayIconPathRuntime(deps: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
@@ -39,6 +41,8 @@ export type TrayMenuActionHandlers = {
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
openHachidoriSettings: () => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettings: () => void;
|
||||
openSyncUi: () => void;
|
||||
@@ -102,8 +106,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
label:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? 'Open Hachidori Settings'
|
||||
: 'Open Yomitan Settings',
|
||||
click:
|
||||
handlers.dictionaryBackend === 'hachidori'
|
||||
? handlers.openHachidoriSettings
|
||||
: handlers.openYomitanSettings,
|
||||
},
|
||||
{
|
||||
label: 'Open SubMiner Settings',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
|
||||
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||
assert.notEqual(
|
||||
@@ -16,3 +17,15 @@ test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('settings sync key changes when fields or tags change', () => {
|
||||
const key = (word: string, tags: string[]) =>
|
||||
buildYomitanAnkiSettingsKey({
|
||||
targetUrl: 'http://127.0.0.1:8766',
|
||||
targetDeck: 'Mining',
|
||||
forceOverride: true,
|
||||
hachidoriHints: buildHachidoriAnkiHints({ fields: { word }, tags }),
|
||||
});
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Expression', ['SubMiner']));
|
||||
assert.notEqual(key('Word', ['SubMiner']), key('Word', ['Japanese']));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { buildHachidoriAnkiHints } from '../../core/services/tokenizer/hachidori-anki-settings';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
@@ -17,8 +18,9 @@ export function buildYomitanAnkiSettingsKey(options: {
|
||||
targetUrl: string;
|
||||
targetDeck: string;
|
||||
forceOverride: boolean;
|
||||
hachidoriHints?: ReturnType<typeof buildHachidoriAnkiHints>;
|
||||
}): string {
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}\n${JSON.stringify(options.hachidoriHints)}`;
|
||||
}
|
||||
|
||||
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||
@@ -45,6 +47,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
targetUrl,
|
||||
targetDeck,
|
||||
forceOverride,
|
||||
hachidoriHints: buildHachidoriAnkiHints(ankiConnectConfig),
|
||||
});
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
@@ -64,6 +67,7 @@ export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRu
|
||||
{
|
||||
forceOverride,
|
||||
deck: targetDeck,
|
||||
ankiConfig: ankiConnectConfig,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ test('sidebar clipboard bridge writes exact text without renderer focus and reje
|
||||
let exposed: unknown;
|
||||
runInNewContext(output.text, {
|
||||
process: { argv: [] },
|
||||
window: { addEventListener: () => {} },
|
||||
require: (name: string) => {
|
||||
assert.equal(name, 'electron');
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,44 @@
|
||||
|
||||
import { clipboard, contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
|
||||
import { resolveOverlayLayerFromArgv } from './preload-args';
|
||||
import {
|
||||
DICTIONARY_EXTERNAL_LINK_CHANNEL,
|
||||
parseDictionaryExternalUrl,
|
||||
} from './shared/dictionary-external-link';
|
||||
|
||||
window.addEventListener('hachidori-open-external', (event) => {
|
||||
if (!(event instanceof CustomEvent)) return;
|
||||
const detail: unknown = event.detail;
|
||||
if (
|
||||
!detail ||
|
||||
typeof detail !== 'object' ||
|
||||
!('requestId' in detail) ||
|
||||
typeof detail.requestId !== 'string' ||
|
||||
!('url' in detail)
|
||||
)
|
||||
return;
|
||||
const requestId = detail.requestId;
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
ipcRenderer.invoke(DICTIONARY_EXTERNAL_LINK_CHANNEL, parseDictionaryExternalUrl(detail.url)),
|
||||
)
|
||||
.then(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('hachidori-open-external-result', { detail: { requestId, ok: true } }),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('hachidori-open-external-result', {
|
||||
detail: {
|
||||
requestId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
import type {
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
|
||||
@@ -368,7 +368,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
{ shortcut: 'Y then S', action: 'Start overlay' },
|
||||
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
|
||||
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
|
||||
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
|
||||
{ shortcut: 'Y then O', action: 'Open dictionary settings' },
|
||||
{ shortcut: 'Y then R', action: 'Restart overlay' },
|
||||
{ shortcut: 'Y then C', action: 'Check overlay status' },
|
||||
{ shortcut: 'Y then H/K', action: 'Open session help' },
|
||||
@@ -377,7 +377,7 @@ function buildFixedOverlaySections(): SessionHelpSection[] {
|
||||
},
|
||||
{
|
||||
title: 'Global shortcuts',
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
|
||||
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open dictionary settings' }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ test('youtube track picker close restores focus and mouse-ignore state', () => {
|
||||
assert.equal(state.youtubePickerModalOpen, false);
|
||||
assert.deepEqual(syncCalls, ['sync', 'sync', 'restore-pointer']);
|
||||
assert.deepEqual(notifications, ['youtube-track-picker']);
|
||||
assert.deepEqual(frontendCommands, [{ type: 'refreshOptions' }]);
|
||||
assert.deepEqual(frontendCommands, []);
|
||||
assert.equal(overlay.classList.contains('interactive'), false);
|
||||
assert.equal(focusMainWindowCalls.length > 0, true);
|
||||
assert.equal(overlayFocusCalls.length > 0, true);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||
|
||||
function createOption(value: string, label: string): HTMLOptionElement {
|
||||
const option = document.createElement('option');
|
||||
@@ -197,13 +196,6 @@ export function createYoutubeTrackPickerModal(
|
||||
ctx.dom.youtubePickerModal.classList.add('hidden');
|
||||
ctx.dom.youtubePickerModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('youtube-track-picker');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||
detail: {
|
||||
type: 'refreshOptions',
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
|
||||
@@ -230,12 +230,12 @@ async function loadYomitanAnkiDeckName(): Promise<void> {
|
||||
state.yomitanAnkiDeckNameError = null;
|
||||
} else {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read Yomitan Anki deck.';
|
||||
state.yomitanAnkiDeckNameError = result.error ?? 'Failed to read the dictionary Anki deck.';
|
||||
}
|
||||
} catch (error) {
|
||||
state.yomitanAnkiDeckName = '';
|
||||
state.yomitanAnkiDeckNameError =
|
||||
error instanceof Error ? error.message : 'Failed to read Yomitan Anki deck.';
|
||||
error instanceof Error ? error.message : 'Failed to read the dictionary Anki deck.';
|
||||
} finally {
|
||||
state.yomitanAnkiDeckNameLoading = false;
|
||||
requestRender();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseDictionaryExternalUrl } from './dictionary-external-link';
|
||||
|
||||
test('dictionary links accept web URLs and reject privileged schemes and credentials', () => {
|
||||
assert.equal(
|
||||
parseDictionaryExternalUrl('https://example.com/word?q=猫'),
|
||||
'https://example.com/word?q=%E7%8C%AB',
|
||||
);
|
||||
for (const value of [
|
||||
'file:///etc/passwd',
|
||||
'javascript:alert(1)',
|
||||
'https://user:pass@example.com',
|
||||
null,
|
||||
{},
|
||||
]) {
|
||||
assert.throws(() => parseDictionaryExternalUrl(value));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DICTIONARY_EXTERNAL_LINK_CHANNEL = 'dictionary:open-external';
|
||||
|
||||
export function parseDictionaryExternalUrl(value: unknown): string {
|
||||
if (typeof value !== 'string') throw new Error('Expected a link URL');
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
||||
throw new Error('Dictionary links must use HTTP or HTTPS without credentials');
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildHachidoriSharingScript, parseHachidoriHostStatus } from './hachidori-sharing';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
test('host status rejects malformed replies and distinguishes local, connected and offline', () => {
|
||||
assert.deepEqual(parseHachidoriHostStatus({ ok: true, sharing: { client: { linked: false } } }), {
|
||||
kind: 'local',
|
||||
});
|
||||
assert.throws(
|
||||
() => parseHachidoriHostStatus({ ok: false, error: 'Connection refused' }),
|
||||
/Connection refused/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseHachidoriHostStatus({
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: { linked: true, connected: true, address: 'host', host: { dictionaryCount: -1 } },
|
||||
},
|
||||
}),
|
||||
/dictionary count/,
|
||||
);
|
||||
assert.equal(
|
||||
parseHachidoriHostStatus({
|
||||
ok: true,
|
||||
sharing: { client: { linked: true, address: 'host', connected: false } },
|
||||
}).kind,
|
||||
'disconnected',
|
||||
);
|
||||
});
|
||||
|
||||
test('sharing script safely links an address and checks live engine inventory', async () => {
|
||||
const address = `ws://host:8771/link?quote="`;
|
||||
const requests: Array<Record<string, unknown>> = [];
|
||||
const script = buildHachidoriSharingScript({ type: 'hd_sharing_client_link', address });
|
||||
const value: unknown = await runInNewContext(script, {
|
||||
crypto,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: Record<string, unknown>) => {
|
||||
requests.push(message);
|
||||
if (message.type === 'hd_status') return { ok: true, ready: true, loading: false };
|
||||
if (message.type === 'hd_state_read')
|
||||
return { ok: true, state: { dictionaries: [{}, {}] } };
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address,
|
||||
host: { name: 'Host', dictionaryCount: 99 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(requests[0]?.address, address);
|
||||
assert.deepEqual(parseHachidoriHostStatus(value), {
|
||||
kind: 'connected',
|
||||
address,
|
||||
name: 'Host',
|
||||
dictionaryCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('an unresponsive host preserves its address so setup can still unlink', async () => {
|
||||
const value: unknown = await runInNewContext(
|
||||
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
|
||||
{
|
||||
crypto,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: { type: string }) => {
|
||||
if (message.type === 'hd_status') throw new Error('Host stopped responding');
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address: 'ws://host:8771/link',
|
||||
host: { dictionaryCount: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseHachidoriHostStatus(value), {
|
||||
kind: 'disconnected',
|
||||
address: 'ws://host:8771/link',
|
||||
message: 'Host stopped responding',
|
||||
});
|
||||
});
|
||||
|
||||
for (const stalledCall of ['initial', 'refresh']) {
|
||||
test(`sharing ${stalledCall} requests time out and clear their timers`, async () => {
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextTimer = 0;
|
||||
let sharingCalls = 0;
|
||||
let stalled = false;
|
||||
const result: Promise<unknown> = runInNewContext(
|
||||
buildHachidoriSharingScript({ type: 'hd_sharing_status' }),
|
||||
{
|
||||
crypto,
|
||||
setTimeout: (callback: () => void, delay: number) => {
|
||||
assert.equal(delay, 5000);
|
||||
timers.set(++nextTimer, callback);
|
||||
return nextTimer;
|
||||
},
|
||||
clearTimeout: (id: number) => timers.delete(id),
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage: async (message: { type: string }) => {
|
||||
if (message.type === 'hd_status') return { ok: true, ready: true };
|
||||
sharingCalls += 1;
|
||||
if (sharingCalls === (stalledCall === 'initial' ? 1 : 2)) {
|
||||
stalled = true;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
sharing: {
|
||||
client: {
|
||||
linked: true,
|
||||
connected: true,
|
||||
address: 'host',
|
||||
host: { dictionaryCount: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 20 && !stalled; i++) await Promise.resolve();
|
||||
assert.equal(stalled, true);
|
||||
assert.equal(timers.size, 1);
|
||||
for (const callback of timers.values()) callback();
|
||||
if (stalledCall === 'initial') {
|
||||
await assert.rejects(result, /host did not respond/);
|
||||
} else {
|
||||
const status = parseHachidoriHostStatus(await result);
|
||||
assert.equal(status.kind, 'disconnected');
|
||||
if (status.kind === 'disconnected') assert.match(status.message, /host did not respond/);
|
||||
}
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export type HachidoriHostStatus =
|
||||
| { kind: 'local' }
|
||||
| { kind: 'connected'; address: string; name: string; dictionaryCount: number }
|
||||
| { kind: 'disconnected'; address: string; message: string }
|
||||
| { kind: 'unavailable'; message: string };
|
||||
|
||||
export type HachidoriSharingRequest =
|
||||
| { type: 'hd_sharing_status' }
|
||||
| { type: 'hd_sharing_client_link'; address: string }
|
||||
| { type: 'hd_sharing_client_unlink' };
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function parseHachidoriHostStatus(reply: unknown): HachidoriHostStatus {
|
||||
if (!object(reply) || reply.ok !== true || !object(reply.sharing)) {
|
||||
throw new Error(
|
||||
object(reply) && typeof reply.error === 'string'
|
||||
? reply.error
|
||||
: 'Hachidori returned an invalid sharing status.',
|
||||
);
|
||||
}
|
||||
const client = reply.sharing.client;
|
||||
if (!object(client) || typeof client.linked !== 'boolean') {
|
||||
throw new Error('Hachidori returned an invalid host connection.');
|
||||
}
|
||||
if (!client.linked) return { kind: 'local' };
|
||||
if (typeof client.address !== 'string') throw new Error('Missing Hachidori host address.');
|
||||
if (client.connected !== true) {
|
||||
return {
|
||||
kind: 'disconnected',
|
||||
address: client.address,
|
||||
message:
|
||||
typeof client.error === 'string'
|
||||
? client.error
|
||||
: 'The dictionary host is not reachable. Start it, then refresh status.',
|
||||
};
|
||||
}
|
||||
const host = client.host;
|
||||
if (
|
||||
!object(host) ||
|
||||
typeof host.dictionaryCount !== 'number' ||
|
||||
!Number.isSafeInteger(host.dictionaryCount) ||
|
||||
host.dictionaryCount < 0
|
||||
) {
|
||||
throw new Error('Hachidori returned an invalid dictionary count.');
|
||||
}
|
||||
return {
|
||||
kind: 'connected',
|
||||
address: client.address,
|
||||
name: typeof host.name === 'string' ? host.name : 'Hachidori',
|
||||
dictionaryCount: host.dictionaryCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHachidoriSharingScript(request: HachidoriSharingRequest): string {
|
||||
return `(async () => {
|
||||
const request = ${JSON.stringify(request)};
|
||||
const requestWithTimeout = async fields => {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
chrome.runtime.sendMessage({ requestId: crypto.randomUUID(), ...fields }),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('The dictionary host did not respond. Check the host and refresh status.')), 5000); }),
|
||||
]);
|
||||
} finally { clearTimeout(timer); }
|
||||
};
|
||||
const send = fields => requestWithTimeout({ target: 'hachidori-sharing', ...fields });
|
||||
const read = (target, type) => requestWithTimeout({ target, type });
|
||||
let reply = await send(request);
|
||||
if (!reply?.ok) throw new Error(reply?.error || 'Hachidori could not update the host connection.');
|
||||
if (reply.sharing?.client?.linked && (reply.sharing.client.connected || request.type === 'hd_sharing_client_link')) {
|
||||
try {
|
||||
const engine = await read('hoshidicts-offscreen', 'hd_status');
|
||||
reply = await send({type: 'hd_sharing_status'});
|
||||
if (engine?.ok && engine.ready && !engine.loading && reply.sharing?.client?.connected) {
|
||||
const inventory = await read('hoshidicts-worker', 'hd_state_read');
|
||||
if (!inventory?.ok || !Array.isArray(inventory.state?.dictionaries)) throw new Error('Could not read the dictionary host library.');
|
||||
reply.sharing.client.host.dictionaryCount = inventory.state.dictionaries.length;
|
||||
} else if (reply.sharing?.client?.linked) {
|
||||
reply.sharing.client.connected = false;
|
||||
reply.sharing.client.error = engine?.loading ? 'The dictionary host is loading. Refresh status when it is ready.' : (engine?.error || 'The dictionary host is not ready.');
|
||||
}
|
||||
} catch (error) {
|
||||
reply.sharing.client.connected = false;
|
||||
reply.sharing.client.error = error.message;
|
||||
}
|
||||
}
|
||||
return reply;
|
||||
})()`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStatePath,
|
||||
isSetupCompleted,
|
||||
normalizeSetupState,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
@@ -333,3 +334,42 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test('setup state keeps backend identity and reads legacy completion as Yomitan', () => {
|
||||
const legacy = { ...createDefaultSetupState(), status: 'completed' as const };
|
||||
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'yomitan'), true);
|
||||
assert.equal(isSetupCompleted(normalizeSetupState(legacy), 'hachidori'), false);
|
||||
const hachidori = normalizeSetupState({ ...legacy, dictionaryBackend: 'hachidori' });
|
||||
assert.equal(hachidori?.dictionaryBackend, 'hachidori');
|
||||
assert.equal(isSetupCompleted(hachidori, 'hachidori'), true);
|
||||
assert.equal(isSetupCompleted(hachidori, 'yomitan'), false);
|
||||
assert.equal(normalizeSetupState({ ...legacy, dictionaryBackend: 'unknown' }), null);
|
||||
});
|
||||
|
||||
test('setup state remembers every backend that finished setup', () => {
|
||||
const state = normalizeSetupState({
|
||||
...createDefaultSetupState(),
|
||||
status: 'completed',
|
||||
dictionaryBackend: 'hachidori',
|
||||
completedDictionaryBackends: ['yomitan', 'yomitan', 'bogus'],
|
||||
});
|
||||
assert.deepEqual(state?.completedDictionaryBackends, ['yomitan']);
|
||||
assert.equal(isSetupCompleted(state, 'yomitan'), true);
|
||||
assert.equal(isSetupCompleted(state, 'hachidori'), true);
|
||||
const reopened = normalizeSetupState({ ...state, status: 'incomplete' });
|
||||
assert.equal(isSetupCompleted(reopened, 'hachidori'), false);
|
||||
assert.equal(isSetupCompleted(reopened, 'yomitan'), true);
|
||||
for (const status of ['incomplete', 'in_progress', 'cancelled']) {
|
||||
const staleHistory = normalizeSetupState({
|
||||
...state,
|
||||
status,
|
||||
completedDictionaryBackends: ['yomitan', 'hachidori'],
|
||||
});
|
||||
assert.equal(isSetupCompleted(staleHistory, 'hachidori'), false);
|
||||
assert.equal(isSetupCompleted(staleHistory, 'yomitan'), true);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeSetupState({ ...state, completedDictionaryBackends: [] })?.completedDictionaryBackends,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import type { DictionaryBackend } from '../types/config';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveConfigDir } from '../config/path-resolution';
|
||||
@@ -18,6 +19,10 @@ export interface SetupWindowsMpvShortcutPreferences {
|
||||
|
||||
export interface SetupState {
|
||||
version: 4;
|
||||
/** Backend the recorded status belongs to. Missing in legacy state files, which belong to Yomitan. */
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
/** Backends whose setup finished at least once, so switching back never repeats setup. */
|
||||
completedDictionaryBackends?: DictionaryBackend[];
|
||||
status: SetupStateStatus;
|
||||
completedAt: string | null;
|
||||
completionSource: SetupCompletionSource;
|
||||
@@ -51,6 +56,10 @@ function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function isDictionaryBackend(value: unknown): value is DictionaryBackend {
|
||||
return value === 'yomitan' || value === 'hachidori';
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -93,6 +102,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
|
||||
if (
|
||||
(version !== 1 && version !== 2 && version !== 3 && version !== 4) ||
|
||||
(record.dictionaryBackend !== undefined && !isDictionaryBackend(record.dictionaryBackend)) ||
|
||||
(status !== 'incomplete' &&
|
||||
status !== 'in_progress' &&
|
||||
status !== 'completed' &&
|
||||
@@ -127,8 +137,18 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedDictionaryBackends = Array.isArray(record.completedDictionaryBackends)
|
||||
? record.completedDictionaryBackends.filter(isDictionaryBackend)
|
||||
: [];
|
||||
|
||||
return {
|
||||
version: 4,
|
||||
...(isDictionaryBackend(record.dictionaryBackend)
|
||||
? { dictionaryBackend: record.dictionaryBackend }
|
||||
: {}),
|
||||
...(completedDictionaryBackends.length > 0
|
||||
? { completedDictionaryBackends: [...new Set(completedDictionaryBackends)] }
|
||||
: {}),
|
||||
status,
|
||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||
completionSource,
|
||||
@@ -191,8 +211,28 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function isSetupCompleted(state: SetupState | null | undefined): boolean {
|
||||
return state?.status === 'completed';
|
||||
export function getSetupStateDictionaryBackend(state: SetupState): DictionaryBackend {
|
||||
return state.dictionaryBackend ?? 'yomitan';
|
||||
}
|
||||
|
||||
/** Current status takes precedence; completion history applies only to other backends. */
|
||||
export function hasCompletedSetupForBackend(
|
||||
state: SetupState,
|
||||
dictionaryBackend: DictionaryBackend,
|
||||
): boolean {
|
||||
if (getSetupStateDictionaryBackend(state) === dictionaryBackend) {
|
||||
return state.status === 'completed';
|
||||
}
|
||||
return (state.completedDictionaryBackends ?? []).includes(dictionaryBackend);
|
||||
}
|
||||
|
||||
export function isSetupCompleted(
|
||||
state: SetupState | null | undefined,
|
||||
dictionaryBackend?: DictionaryBackend,
|
||||
): boolean {
|
||||
if (!state) return false;
|
||||
if (dictionaryBackend === undefined) return state.status === 'completed';
|
||||
return hasCompletedSetupForBackend(state, dictionaryBackend);
|
||||
}
|
||||
|
||||
export function getDefaultConfigDir(options?: {
|
||||
|
||||
+46
-23
@@ -5,6 +5,14 @@ import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import { ConfigService } from './config/service';
|
||||
import { createLogger, setLogLevel } from './logger';
|
||||
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
|
||||
import {
|
||||
createHachidoriExtensionRuntime,
|
||||
getHachidoriSession,
|
||||
} from './core/services/hachidori-extension';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './main/runtime/yomitan-anki-server';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
getYomitanCurrentAnkiDeckName,
|
||||
@@ -104,27 +112,38 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
const configService = new ConfigService(userDataPath!);
|
||||
const config = configService.getConfig();
|
||||
const extension = await loadYomitanExtension({
|
||||
userDataPath: userDataPath!,
|
||||
getYomitanParserWindow: () => yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
yomitanParserWindow = window;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
yomitanParserReadyPromise = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
yomitanParserInitPromise = promise;
|
||||
},
|
||||
setYomitanExtension: (extensionValue) => {
|
||||
yomitanExt = extensionValue;
|
||||
},
|
||||
setYomitanSession: (sessionValue) => {
|
||||
yomitanSession = sessionValue;
|
||||
},
|
||||
});
|
||||
// Mine with the same backend the app uses so notes come from the user's
|
||||
// dictionaries and Anki templates, not an empty profile.
|
||||
const extension =
|
||||
config.dictionaryBackend === 'hachidori'
|
||||
? await createHachidoriExtensionRuntime(userDataPath!)
|
||||
.ensureLoaded()
|
||||
.then((loaded) => {
|
||||
yomitanExt = loaded;
|
||||
yomitanSession = getHachidoriSession();
|
||||
return loaded;
|
||||
})
|
||||
: await loadYomitanExtension({
|
||||
userDataPath: userDataPath!,
|
||||
getYomitanParserWindow: () => yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
yomitanParserWindow = window;
|
||||
},
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
yomitanParserReadyPromise = promise;
|
||||
},
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
yomitanParserInitPromise = promise;
|
||||
},
|
||||
setYomitanExtension: (extensionValue) => {
|
||||
yomitanExt = extensionValue;
|
||||
},
|
||||
setYomitanSession: (sessionValue) => {
|
||||
yomitanSession = sessionValue;
|
||||
},
|
||||
});
|
||||
if (!extension) {
|
||||
throw new Error('Yomitan extension failed to load.');
|
||||
throw new Error(`${config.dictionaryBackend} extension failed to load.`);
|
||||
}
|
||||
|
||||
const yomitanDeps = {
|
||||
@@ -156,17 +175,21 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
await syncYomitanDefaultAnkiServer(
|
||||
config.ankiConnect?.url || 'http://127.0.0.1:8765',
|
||||
getPreferredYomitanAnkiServerUrl(config.ankiConnect),
|
||||
yomitanDeps,
|
||||
logger,
|
||||
{ forceOverride: true, deck: config.ankiConnect?.deck },
|
||||
{
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(config.ankiConnect),
|
||||
deck: config.ankiConnect?.deck,
|
||||
ankiConfig: config.ankiConnect,
|
||||
},
|
||||
);
|
||||
|
||||
const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
|
||||
|
||||
const noteId = addResult.noteId;
|
||||
if (typeof noteId !== 'number') {
|
||||
throw new Error('Yomitan failed to create note.');
|
||||
throw new Error(`${config.dictionaryBackend} failed to create note.`);
|
||||
}
|
||||
|
||||
writeResponse(responsePath, {
|
||||
|
||||
@@ -140,7 +140,10 @@ export interface RawShortcutsConfig extends ShortcutsConfig {
|
||||
openAnimetosho?: string | null;
|
||||
}
|
||||
|
||||
export type DictionaryBackend = 'yomitan' | 'hachidori';
|
||||
|
||||
export interface Config {
|
||||
dictionaryBackend?: DictionaryBackend;
|
||||
subtitlePosition?: SubtitlePosition;
|
||||
keybindings?: Keybinding[];
|
||||
websocket?: WebSocketConfig;
|
||||
@@ -183,6 +186,7 @@ export interface Config {
|
||||
export type RawConfig = Config;
|
||||
|
||||
export interface ResolvedConfig {
|
||||
dictionaryBackend: DictionaryBackend;
|
||||
subtitlePosition: SubtitlePosition;
|
||||
keybindings: Keybinding[];
|
||||
websocket: Required<WebSocketConfig>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user