From 3932e53ced18443b53d6945e1605e915625c2976 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 25 May 2026 18:29:20 -0700 Subject: [PATCH] feat(character-dictionary): add manager modal and scope name matching to current media (#86) --- changes/character-dictionary-manager.md | 4 + config.example.jsonc | 2 +- docs-site/changelog.md | 4 + docs-site/character-dictionary.md | 25 +- docs-site/configuration.md | 6 +- docs-site/public/config.example.jsonc | 2 +- docs-site/shortcuts.md | 24 +- docs-site/subtitle-annotations.md | 2 +- package.json | 2 +- src/cli/help.ts | 2 +- src/config/config.test.ts | 3 +- src/config/definitions/defaults-core.ts | 2 +- src/config/definitions/options-core.ts | 6 +- src/config/resolve/core-domains.ts | 2 +- src/config/settings/registry.test.ts | 13 + src/config/settings/registry.ts | 4 +- src/core/services/cli-command.test.ts | 16 + src/core/services/cli-command.ts | 4 +- .../hyprland-window-placement.test.ts | 34 ++ .../services/hyprland-window-placement.ts | 14 +- src/core/services/ipc.ts | 86 ++++- src/core/services/overlay-manager.test.ts | 26 ++ src/core/services/overlay-manager.ts | 19 +- .../services/overlay-shortcut-handler.test.ts | 17 +- src/core/services/overlay-shortcut-handler.ts | 10 +- src/core/services/overlay-shortcut.test.ts | 5 +- src/core/services/overlay-shortcut.ts | 3 +- src/core/services/overlay-window.ts | 9 +- src/core/services/session-actions.test.ts | 9 + src/core/services/session-actions.ts | 6 +- src/core/services/session-bindings.test.ts | 39 ++- src/core/services/session-bindings.ts | 2 +- src/core/services/tokenizer.ts | 4 + .../tokenizer/yomitan-parser-runtime.test.ts | 152 +++++++++ .../tokenizer/yomitan-parser-runtime.ts | 97 +++++- src/core/utils/shortcut-config.test.ts | 4 +- src/core/utils/shortcut-config.ts | 6 +- src/main.ts | 112 ++++++- .../build.test.ts | 45 +++ .../character-dictionary-runtime/constants.ts | 2 +- .../character-dictionary-runtime/glossary.ts | 3 +- .../image-lookup.test.ts | 98 +++++- .../image-lookup.ts | 30 +- .../character-dictionary-runtime/snapshot.ts | 1 + src/main/dependencies.ts | 6 + src/main/overlay-shortcuts-runtime.ts | 4 + ...dictionary-auto-sync-notifications.test.ts | 5 +- .../character-dictionary-auto-sync.test.ts | 95 +++++- .../runtime/character-dictionary-auto-sync.ts | 185 ++++++++++- src/main/runtime/character-dictionary-open.ts | 36 ++- .../global-shortcuts-runtime-handlers.test.ts | 2 +- src/main/runtime/global-shortcuts.test.ts | 2 +- ...verlay-shortcuts-runtime-main-deps.test.ts | 3 + .../overlay-shortcuts-runtime-main-deps.ts | 1 + .../subtitle-tokenization-main-deps.ts | 8 + src/main/runtime/tray-main-actions.test.ts | 6 +- src/main/runtime/tray-main-deps.test.ts | 3 +- src/preload.ts | 27 +- src/renderer/handlers/keyboard.test.ts | 2 +- src/renderer/index.html | 62 +++- .../modals/character-dictionary.test.ts | 304 +++++++++++++++++- src/renderer/modals/character-dictionary.ts | 215 ++++++++++++- src/renderer/modals/session-help-sections.ts | 5 +- src/renderer/renderer.ts | 5 + src/renderer/style.css | 33 ++ src/renderer/utils/dom.ts | 20 ++ src/shared/ipc/contracts.ts | 4 + src/shared/ipc/validators.ts | 1 + src/types/config.ts | 2 +- src/types/runtime.ts | 30 +- src/types/session-bindings.ts | 1 + 71 files changed, 1896 insertions(+), 127 deletions(-) create mode 100644 changes/character-dictionary-manager.md diff --git a/changes/character-dictionary-manager.md b/changes/character-dictionary-manager.md new file mode 100644 index 00000000..e96c3066 --- /dev/null +++ b/changes/character-dictionary-manager.md @@ -0,0 +1,4 @@ +type: changed +area: character-dictionary + +- Character dictionary entries are now scoped to the current AniList media for name matching and inline portraits, and a new `Ctrl/Cmd+D` manager modal can remove, reorder, or override loaded dictionary entries. diff --git a/config.example.jsonc b/config.example.jsonc index 83da260f..ebcfb941 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -187,7 +187,7 @@ "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. - "openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. + "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. diff --git a/docs-site/changelog.md b/docs-site/changelog.md index 925c01d7..84410370 100644 --- a/docs-site/changelog.md +++ b/docs-site/changelog.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Character Dictionary:** Loaded entries are now scoped to the current AniList media for subtitle name matching and inline portraits. Added a character dictionary manager at `Ctrl/Cmd+D`; AniList overrides now live inside that manager instead of using a separate default shortcut. + ## v0.14.0 (2026-05-12) SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 7cc4d491..ea1c959d 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -14,7 +14,7 @@ The feature has three stages: **snapshot**, **merge**, and **match**. 2. **Merge** — SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP — `character-dictionaries/merged.zip` — which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds. -3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color. +3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color. ## Enabling the Feature @@ -89,9 +89,10 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati 1. Yomitan receives subtitle text and scans for dictionary matches. 2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`. -3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer. -4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`). -5. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data. +3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits. +4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer. +5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`). +6. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data. Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images. @@ -178,7 +179,7 @@ SubMiner uses `guessit` to infer the anime title from the active filename before Use the in-app selector or CLI to pin the correct AniList media for the whole series: -- In-app: open the selector with `Ctrl/Cmd+Alt+A` or `--open-character-dictionary`, edit the prefilled title if needed, then search and choose the correct result. +- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result. The CLI flag `--open-character-dictionary` still opens the selector directly. - CLI: `--dictionary-candidates` still lists matches for the current filename guess. ```bash @@ -198,6 +199,16 @@ subminer app --open-character-dictionary Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. +## Managing Loaded Entries + +Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries. + +- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once. +- **Up/Down** changes MRU order for future eviction without rebuilding. +- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry. + +The current anime cannot be removed while you are watching it; it stays loaded until playback changes. + ## File Structure All character dictionary data lives under `{userData}/character-dictionaries/`: @@ -215,7 +226,7 @@ character-dictionaries/ m170942-va67890.jpg # Voice actor portrait ``` -**Snapshot format** (v16): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. +**Snapshot format** (v17): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. **ZIP structure** follows the Yomitan dictionary format: @@ -264,7 +275,7 @@ If you work with visual novels or want a standalone dictionary generator indepen - **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. -- **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`), edit the search title, and select the right AniList entry. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`. +- **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known. - **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this. - **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index a2756fc8..5b58a94d 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -618,7 +618,7 @@ See `config.example.jsonc` for detailed configuration options. "mineSentence": "CommandOrControl+S", "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", - "openCharacterDictionary": "CommandOrControl+Alt+A", + "openCharacterDictionaryManager": "CommandOrControl+D", "openRuntimeOptions": "CommandOrControl+Shift+O", "openSessionHelp": "CommandOrControl+Slash", "openControllerSelect": "Alt+C", @@ -643,7 +643,7 @@ See `config.example.jsonc` for detailed configuration options. | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | -| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) | +| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | | `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | @@ -787,7 +787,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | -| `Ctrl+Alt+A` | Open character dictionary AniList selector | +| `Ctrl+D` | Open loaded character dictionary manager | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 83da260f..ebcfb941 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -187,7 +187,7 @@ "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility. "markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card. - "openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal. + "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 071fc216..5952624f 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -75,17 +75,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle ## Subtitle & Feature Shortcuts -| Shortcut | Action | Config key | -| ------------------ | -------------------------------------------------------- | ----------------------------------- | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | -| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` | -| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | -| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | -| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | -| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | -| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | -| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | -| `` ` `` | Toggle stats overlay | `stats.toggleKey` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ----------------------------------------------- | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | +| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` | +| `` ` `` | Toggle stats overlay | `stats.toggleKey` | The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. @@ -131,7 +131,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel ## Customizing Shortcuts -All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut. +All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+D"`. Use `null` to disable a shortcut. ```jsonc { diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index 72fedf10..34a59a18 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -37,7 +37,7 @@ Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection ## Character-Name Highlighting -Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail. +Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail. **How it works:** diff --git a/package.json b/package.json index d9c23b54..6f48e9b2 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", diff --git a/src/cli/help.ts b/src/cli/help.ts index e9588286..13d6cfc3 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -43,7 +43,7 @@ ${B}Mining${R} --toggle-subtitle-sidebar Toggle subtitle sidebar panel --open-runtime-options Open runtime options palette --open-session-help Open session help modal - --open-character-dictionary Open character dictionary anime selection modal + --open-character-dictionary Open character dictionary management modal --open-controller-select Open controller select modal --open-controller-debug Open controller debug modal diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 68ff68c9..fcade58e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -96,7 +96,8 @@ test('loads defaults when config is missing', () => { assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, false); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); - assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A'); + assert.equal('openCharacterDictionary' in config.shortcuts, false); + assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.updateIntervalMs, 3_000); diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 9c05eb81..4a5e596a 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -88,7 +88,7 @@ export const CORE_DEFAULT_CONFIG: Pick< multiCopyTimeoutMs: 3000, toggleSecondarySub: 'CommandOrControl+Shift+V', markAudioCard: 'CommandOrControl+Shift+A', - openCharacterDictionary: 'CommandOrControl+Alt+A', + openCharacterDictionaryManager: 'CommandOrControl+D', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', openSessionHelp: 'CommandOrControl+Slash', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 074fa362..636bbcb2 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -542,10 +542,10 @@ export function buildCoreConfigOptionRegistry( description: 'Accelerator that marks the last mined card as an audio card.', }, { - path: 'shortcuts.openCharacterDictionary', + path: 'shortcuts.openCharacterDictionaryManager', kind: 'string', - defaultValue: defaultConfig.shortcuts.openCharacterDictionary, - description: 'Accelerator that opens the character dictionary modal.', + defaultValue: defaultConfig.shortcuts.openCharacterDictionaryManager, + description: 'Accelerator that opens the character dictionary manager modal.', }, { path: 'shortcuts.openRuntimeOptions', diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 4717f158..2b259e12 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -207,7 +207,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'mineSentenceMultiple', 'toggleSecondarySub', 'markAudioCard', - 'openCharacterDictionary', + 'openCharacterDictionaryManager', 'openRuntimeOptions', 'openJimaku', ] as const; diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 4f9ece6b..09e66122 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -54,9 +54,22 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload'); }); +test('settings registry exposes character dictionary panel shortcuts dynamically', () => { + assert.equal( + fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'), + false, + ); + assert.equal( + field('shortcuts.openCharacterDictionaryManager').label, + 'Open Character Dictionary Manager', + ); + assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels'); +}); + test('settings registry hides removed modal-only fields', () => { for (const path of [ 'shortcuts.multiCopyTimeoutMs', + 'shortcuts.openCharacterDictionary', 'anilist.characterDictionary.profileScope', 'jellyfin.directPlayContainers', ]) { diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index e9da6c20..65fb12df 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -208,7 +208,7 @@ const LABEL_OVERRIDES: Record = { 'ankiConnect.isLapis.enabled': 'Enable Lapis Features', 'ankiConnect.isKiku.enabled': 'Enable Kiku Features', 'stats.toggleKey': 'Toggle Stats Overlay', - 'shortcuts.openCharacterDictionary': 'Open AniList Override', + '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', @@ -570,7 +570,7 @@ function subsectionForPath(path: string): string | undefined { return 'Toggle & Visibility'; } if ( - leaf === 'openCharacterDictionary' || + leaf === 'openCharacterDictionaryManager' || leaf === 'openRuntimeOptions' || leaf === 'openJimaku' || leaf === 'openSessionHelp' || diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 73455a44..7951e41d 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -801,6 +801,22 @@ test('handleCliCommand dispatches mark-watched session action', async () => { }); }); +test('handleCliCommand opens character dictionary manager from CLI flag', async () => { + let request: unknown = null; + const { deps } = createDeps({ + dispatchSessionAction: async (nextRequest) => { + request = nextRequest; + }, + }); + + handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(request, { + actionId: 'openCharacterDictionaryManager', + }); +}); + test('handleCliCommand logs AniList status details', () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index a1041ccd..a24449cc 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -492,8 +492,8 @@ export function handleCliCommand( ); } else if (args.openCharacterDictionary) { dispatchCliSessionAction( - { actionId: 'openCharacterDictionary' }, - 'openCharacterDictionary', + { actionId: 'openCharacterDictionaryManager' }, + 'openCharacterDictionaryManager', 'Open character dictionary failed', ); } else if (args.openControllerSelect) { diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 2951e67a..45a2b00d 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -106,6 +106,40 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win ); }); +test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => { + const buildDispatches = buildHyprlandPlacementDispatches as ( + client: Parameters[0], + bounds: Parameters[1], + options: { promote: false }, + ) => string[][]; + + assert.deepEqual( + buildDispatches( + { + address: '0xabc', + floating: true, + pinned: false, + }, + { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + { promote: false }, + ), + [ + ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'], + ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'], + ['dispatch', 'setprop', 'address:0xabc rounding 0'], + ['dispatch', 'setprop', 'address:0xabc border_size 0'], + ['dispatch', 'setprop', 'address:0xabc no_shadow 1'], + ['dispatch', 'setprop', 'address:0xabc no_blur 1'], + ['dispatch', 'setprop', 'address:0xabc decorate 0'], + ], + ); +}); + test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => { assert.deepEqual( buildHyprlandPlacementDispatches({ diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index 3512597e..46775824 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -18,6 +18,10 @@ export interface HyprlandPlacementBounds { height: number; } +export interface HyprlandPlacementDispatchOptions { + promote?: boolean; +} + type ExecFileSync = typeof execFileSync; export function shouldAttemptHyprlandWindowPlacement( @@ -64,6 +68,7 @@ export function findHyprlandWindowForPlacement( export function buildHyprlandPlacementDispatches( client: HyprlandPlacementClient, bounds?: HyprlandPlacementBounds | null, + options: HyprlandPlacementDispatchOptions = {}, ): string[][] { if (!client.address) { return []; @@ -95,7 +100,9 @@ export function buildHyprlandPlacementDispatches( dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); } - dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]); + if (options.promote !== false) { + dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]); + } return dispatches; } @@ -127,6 +134,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pid?: number; + promote?: boolean; execFileSync?: ExecFileSync; }): boolean { if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) { @@ -146,7 +154,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: { return false; } - const dispatches = buildHyprlandPlacementDispatches(client, options.bounds); + const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, { + promote: options.promote, + }); for (const args of dispatches) { run('hyprctl', args, { stdio: 'ignore' }); } diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index c5cd354a..ed66f003 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -96,7 +96,14 @@ export interface IpcServiceDeps { retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; - setCharacterDictionarySelection?: (mediaId: number) => Promise; + setCharacterDictionarySelection?: ( + mediaId: number, + replaceManagedMediaId?: number, + mediaTitle?: string, + ) => Promise; + getCharacterDictionaryManagerSnapshot?: () => Promise; + removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; + moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -224,7 +231,14 @@ export interface IpcDepsRuntimeOptions { retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; runAnilistPostWatchUpdateOnManualMark?: () => Promise; getCharacterDictionarySelection?: (searchTitle?: string) => Promise; - setCharacterDictionarySelection?: (mediaId: number) => Promise; + setCharacterDictionarySelection?: ( + mediaId: number, + replaceManagedMediaId?: number, + mediaTitle?: string, + ) => Promise; + getCharacterDictionaryManagerSnapshot?: () => Promise; + removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise; + moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; getPlaylistBrowserSnapshot: () => Promise; appendPlaylistBrowserFile: (filePath: string) => Promise; @@ -317,6 +331,22 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService selected: { id: 0, title: '', episodes: null }, staleMediaIds: [], })), + getCharacterDictionaryManagerSnapshot: + options.getCharacterDictionaryManagerSnapshot ?? (async () => ({ entries: [] })), + removeCharacterDictionaryManagedEntry: + options.removeCharacterDictionaryManagedEntry ?? + (async () => ({ + ok: false, + message: 'Character dictionary manager unavailable.', + entries: [], + })), + moveCharacterDictionaryManagedEntry: + options.moveCharacterDictionaryManagedEntry ?? + (async () => ({ + ok: false, + message: 'Character dictionary manager unavailable.', + entries: [], + })), appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: options.appendPlaylistBrowserFile, @@ -629,11 +659,21 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar ipc.handle( IPC_CHANNELS.request.setCharacterDictionarySelection, - async (_event, mediaId: unknown) => { + async (_event, mediaId: unknown, replaceManagedMediaId: unknown, mediaTitle: unknown) => { if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) { return { ok: false, message: 'Invalid AniList media ID.' }; } - return await (deps.setCharacterDictionarySelection?.(mediaId as number) ?? + const normalizedReplaceManagedMediaId = + Number.isSafeInteger(replaceManagedMediaId) && (replaceManagedMediaId as number) > 0 + ? (replaceManagedMediaId as number) + : undefined; + const normalizedMediaTitle = + typeof mediaTitle === 'string' && mediaTitle.trim() ? mediaTitle.trim() : undefined; + return await (deps.setCharacterDictionarySelection?.( + mediaId as number, + normalizedReplaceManagedMediaId, + normalizedMediaTitle, + ) ?? Promise.resolve({ ok: false, message: 'Character dictionary selection unavailable.', @@ -641,6 +681,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); + ipc.handle(IPC_CHANNELS.request.getCharacterDictionaryManagerSnapshot, async () => { + return await (deps.getCharacterDictionaryManagerSnapshot?.() ?? + Promise.resolve({ entries: [] })); + }); + + ipc.handle( + IPC_CHANNELS.request.removeCharacterDictionaryManagedEntry, + async (_event, mediaId: unknown) => { + if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) { + return { ok: false, message: 'Invalid AniList media ID.', entries: [] }; + } + return await (deps.removeCharacterDictionaryManagedEntry?.(mediaId as number) ?? + Promise.resolve({ + ok: false, + message: 'Character dictionary manager unavailable.', + entries: [], + })); + }, + ); + + ipc.handle( + IPC_CHANNELS.request.moveCharacterDictionaryManagedEntry, + async (_event, mediaId: unknown, direction: unknown) => { + if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) { + return { ok: false, message: 'Invalid AniList media ID.', entries: [] }; + } + if (direction !== 1 && direction !== -1) { + return { ok: false, message: 'Invalid move direction.', entries: [] }; + } + return await (deps.moveCharacterDictionaryManagedEntry?.(mediaId as number, direction) ?? + Promise.resolve({ + ok: false, + message: 'Character dictionary manager unavailable.', + entries: [], + })); + }, + ); + ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { return deps.appendClipboardVideoToQueue(); }); diff --git a/src/core/services/overlay-manager.test.ts b/src/core/services/overlay-manager.test.ts index d557b633..23085386 100644 --- a/src/core/services/overlay-manager.test.ts +++ b/src/core/services/overlay-manager.test.ts @@ -110,6 +110,32 @@ test('overlay manager applies bounds for main and modal windows', () => { assert.deepEqual(modalCalls, [{ x: 80, y: 90, width: 100, height: 110 }]); }); +test('overlay manager can suppress z-order promotion during bounds updates', () => { + const calls: string[] = []; + const createManager = createOverlayManager as unknown as (options: { + updateOverlayWindowBounds: ( + geometry: Electron.Rectangle, + window: Electron.BrowserWindow | null, + options: { promote: boolean }, + ) => void; + shouldPromoteWindowOnBoundsUpdate: (window: Electron.BrowserWindow) => boolean; + }) => ReturnType; + const manager = createManager({ + updateOverlayWindowBounds: (_geometry, _window, options) => { + calls.push(`promote:${options.promote}`); + }, + shouldPromoteWindowOnBoundsUpdate: () => false, + }); + + manager.setMainWindow({ + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow); + + manager.setOverlayWindowBounds({ x: 1, y: 2, width: 3, height: 4 }); + + assert.deepEqual(calls, ['promote:false']); +}); + test('runtime-option broadcast still uses expected channel', () => { const broadcasts: unknown[][] = []; broadcastRuntimeOptionsChangedRuntime( diff --git a/src/core/services/overlay-manager.ts b/src/core/services/overlay-manager.ts index 942c81b6..a72f9338 100644 --- a/src/core/services/overlay-manager.ts +++ b/src/core/services/overlay-manager.ts @@ -16,10 +16,23 @@ export interface OverlayManager { broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void; } -export function createOverlayManager(): OverlayManager { +type UpdateOverlayWindowBounds = typeof updateOverlayWindowBounds; + +export interface OverlayManagerOptions { + updateOverlayWindowBounds?: UpdateOverlayWindowBounds; + shouldPromoteWindowOnBoundsUpdate?: (window: BrowserWindow) => boolean; +} + +export function createOverlayManager(options: OverlayManagerOptions = {}): OverlayManager { let mainWindow: BrowserWindow | null = null; let modalWindow: BrowserWindow | null = null; let visibleOverlayVisible = false; + const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds; + + const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => { + const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true; + applyOverlayBounds(geometry, window, { promote }); + }; return { getMainWindow: () => mainWindow, @@ -32,10 +45,10 @@ export function createOverlayManager(): OverlayManager { }, getOverlayWindow: () => mainWindow, setOverlayWindowBounds: (geometry) => { - updateOverlayWindowBounds(geometry, mainWindow); + updateWindowBounds(geometry, mainWindow); }, setModalWindowBounds: (geometry) => { - updateOverlayWindowBounds(geometry, modalWindow); + updateWindowBounds(geometry, modalWindow); }, getVisibleOverlayVisible: () => visibleOverlayVisible, setVisibleOverlayVisible: (visible) => { diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index fca5d0dc..e7756315 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -25,7 +25,7 @@ function makeShortcuts(overrides: Partial = {}): Configured multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -49,6 +49,9 @@ function createDeps(overrides: Partial = {}) { openCharacterDictionary: () => { calls.push('openCharacterDictionary'); }, + openCharacterDictionaryManager: () => { + calls.push('openCharacterDictionaryManager'); + }, openJimaku: () => { calls.push('openJimaku'); }, @@ -93,6 +96,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', overlayHandlers.copySubtitleMultiple(1111); overlayHandlers.toggleSecondarySub(); overlayHandlers.openRuntimeOptions(); + overlayHandlers.openCharacterDictionaryManager(); overlayHandlers.openJimaku(); overlayHandlers.mineSentenceMultiple(2222); overlayHandlers.updateLastCardFromClipboard(); @@ -104,6 +108,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers', 'copySubtitleMultiple:1111', 'toggleSecondarySub', 'openRuntimeOptions', + 'openCharacterDictionaryManager', 'openJimaku', 'mineSentenceMultiple:2222', 'updateLastCardFromClipboard', @@ -159,6 +164,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions', { openRuntimeOptions: () => handled.push('openRuntimeOptions'), openCharacterDictionary: () => handled.push('openCharacterDictionary'), + openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -192,6 +198,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re { openRuntimeOptions: () => handled.push('openRuntimeOptions'), openCharacterDictionary: () => handled.push('openCharacterDictionary'), + openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -212,6 +219,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re { openRuntimeOptions: () => handled.push('openRuntimeOptions'), openCharacterDictionary: () => handled.push('openCharacterDictionary'), + openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), @@ -249,6 +257,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s { openRuntimeOptions: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -285,6 +294,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', { openRuntimeOptions: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openJimaku: () => {}, markAudioCard: () => {}, copySubtitleMultiple: () => {}, @@ -315,6 +325,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', () openCharacterDictionary: () => { called = true; }, + openCharacterDictionaryManager: () => { + called = true; + }, openJimaku: () => { called = true; }, @@ -398,6 +411,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured', toggleSecondarySub: () => {}, markAudioCard: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -425,6 +439,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active toggleSecondarySub: () => {}, markAudioCard: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index 94556fed..f0556ad1 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -7,6 +7,7 @@ const logger = createLogger('main:overlay-shortcut-handler'); export interface OverlayShortcutFallbackHandlers { openRuntimeOptions: () => void; openCharacterDictionary: () => void; + openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => void; copySubtitleMultiple: (timeoutMs: number) => void; @@ -23,6 +24,7 @@ export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; openCharacterDictionary: () => void; + openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -100,6 +102,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim openCharacterDictionary: () => { deps.openCharacterDictionary(); }, + openCharacterDictionaryManager: () => { + deps.openCharacterDictionaryManager(); + }, openJimaku: () => { deps.openJimaku(); }, @@ -108,6 +113,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim const fallbackHandlers: OverlayShortcutFallbackHandlers = { openRuntimeOptions: overlayHandlers.openRuntimeOptions, openCharacterDictionary: overlayHandlers.openCharacterDictionary, + openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager, openJimaku: overlayHandlers.openJimaku, markAudioCard: overlayHandlers.markAudioCard, copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, @@ -141,9 +147,9 @@ export function runOverlayShortcutLocalFallback( }, }, { - accelerator: shortcuts.openCharacterDictionary, + accelerator: shortcuts.openCharacterDictionaryManager, run: () => { - handlers.openCharacterDictionary(); + handlers.openCharacterDictionaryManager(); }, }, { diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 87598b98..27e47b15 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -20,7 +20,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -44,6 +44,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured' toggleSecondarySub: () => {}, markAudioCard: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -64,6 +65,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent' toggleSecondarySub: () => {}, markAudioCard: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), @@ -86,6 +88,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active toggleSecondarySub: () => {}, markAudioCard: () => {}, openCharacterDictionary: () => {}, + openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, }), diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index 5a873896..be466211 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -11,6 +11,7 @@ export interface OverlayShortcutHandlers { toggleSecondarySub: () => void; markAudioCard: () => void; openCharacterDictionary: () => void; + openCharacterDictionaryManager: () => void; openRuntimeOptions: () => void; openJimaku: () => void; } @@ -32,7 +33,7 @@ const OVERLAY_SHORTCUT_KEYS: Array = {}) { openRuntimeOptionsPalette: () => calls.push('runtime-options'), openSessionHelp: () => calls.push('session-help'), openCharacterDictionary: () => calls.push('character-dictionary'), + openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openControllerSelect: () => calls.push('controller-select'), openControllerDebug: () => calls.push('controller-debug'), openJimaku: () => calls.push('jimaku'), @@ -77,3 +78,11 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops', assert.deepEqual(calls, ['mark-watched']); }); + +test('dispatchSessionAction opens the character dictionary manager', async () => { + const { calls, deps } = createDeps(); + + await dispatchSessionAction({ actionId: 'openCharacterDictionaryManager' }, deps); + + assert.deepEqual(calls, ['character-dictionary-manager']); +}); diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index 819e797b..ae901917 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -19,6 +19,7 @@ export interface SessionActionExecutorDeps { openRuntimeOptionsPalette: () => void; openSessionHelp: () => void; openCharacterDictionary: () => void; + openCharacterDictionaryManager: () => void; openControllerSelect: () => void; openControllerDebug: () => void; openJimaku: () => void; @@ -97,7 +98,10 @@ export async function dispatchSessionAction( deps.openSessionHelp(); return; case 'openCharacterDictionary': - deps.openCharacterDictionary(); + deps.openCharacterDictionaryManager(); + return; + case 'openCharacterDictionaryManager': + deps.openCharacterDictionaryManager(); return; case 'openControllerSelect': deps.openControllerSelect(); diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 6c5e92d7..2c4cd184 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -19,7 +19,7 @@ function createShortcuts(overrides: Partial = {}): Configur multiCopyTimeoutMs: 2500, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, @@ -209,6 +209,41 @@ test('compileSessionBindings keeps default replay and next subtitle session acti assert.equal(next?.actionId, 'playNextSubtitle'); }); +test('compileSessionBindings keeps only the character dictionary manager bound by default', () => { + const result = compileSessionBindings({ + shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG), + keybindings: DEFAULT_KEYBINDINGS, + statsToggleKey: DEFAULT_CONFIG.stats.toggleKey, + platform: 'linux', + rawConfig: DEFAULT_CONFIG, + }); + + const characterDictionaryBindings = result.bindings.flatMap((binding) => { + if (binding.actionType !== 'session-action') return []; + if ( + binding.actionId !== 'openCharacterDictionary' && + binding.actionId !== 'openCharacterDictionaryManager' + ) { + return []; + } + return [ + { + sourcePath: binding.sourcePath, + originalKey: binding.originalKey, + actionId: binding.actionId, + }, + ]; + }); + + assert.deepEqual(characterDictionaryBindings, [ + { + sourcePath: 'shortcuts.openCharacterDictionaryManager', + originalKey: 'CommandOrControl+D', + actionId: 'openCharacterDictionaryManager', + }, + ]); +}); + test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => { const expectedSpecialActions: Record = { [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine', @@ -411,7 +446,7 @@ test('compileSessionBindings wires every configured shortcut key into the shared 'mineSentenceMultiple', 'toggleSecondarySub', 'markAudioCard', - 'openCharacterDictionary', + 'openCharacterDictionaryManager', 'openRuntimeOptions', 'openJimaku', 'openSessionHelp', diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index c22a621d..ab156e99 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -44,7 +44,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{ { key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' }, { key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' }, { key: 'markAudioCard', actionId: 'markAudioCard' }, - { key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' }, + { key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' }, diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index eafbf423..a6477317 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -51,6 +51,7 @@ export interface TokenizerServiceDeps { getNameMatchEnabled?: () => boolean; getNameMatchImagesEnabled?: () => boolean; getCharacterNameImage?: (term: string) => CharacterNameImage | null; + getCurrentCharacterDictionaryMediaId?: () => number | null; getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyRank?: FrequencyDictionaryLookup; @@ -85,6 +86,7 @@ export interface TokenizerDepsRuntimeOptions { getNameMatchEnabled?: () => boolean; getNameMatchImagesEnabled?: () => boolean; getCharacterNameImage?: (term: string) => CharacterNameImage | null; + getCurrentCharacterDictionaryMediaId?: () => number | null; getFrequencyDictionaryEnabled?: () => boolean; getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode; getFrequencyRank?: FrequencyDictionaryLookup; @@ -237,6 +239,7 @@ export function createTokenizerDepsRuntime( getNameMatchEnabled: options.getNameMatchEnabled, getNameMatchImagesEnabled: options.getNameMatchImagesEnabled, getCharacterNameImage: options.getCharacterNameImage, + getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId, getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled, getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'), getFrequencyRank: options.getFrequencyRank, @@ -708,6 +711,7 @@ async function parseWithYomitanInternalParser( ): Promise { const selectedTokens = await requestYomitanScanTokens(text, deps, logger, { includeNameMatchMetadata: options.nameMatchEnabled, + currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null, }); if (!selectedTokens || selectedTokens.length === 0) { return null; diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index 5fc414b9..48fc147e 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -1281,6 +1281,158 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); }); +test('requestYomitanScanTokens ignores SubMiner character entries from other media', async () => { + let scannerScript = ''; + const deps = createDeps(async (script) => { + if (script.includes('termsFind')) { + scannerScript = script; + return []; + } + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + }, + }, + ], + }; + } + return null; + }); + + await requestYomitanScanTokens( + 'カズ', + deps, + { error: () => undefined }, + { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21202 }, + ); + + const result = await runInjectedYomitanScript(scannerScript, (action, params) => { + if (action !== 'termsFind') { + throw new Error(`unexpected action: ${action}`); + } + const text = (params as { text?: string } | undefined)?.text; + if (text !== 'カズ') { + return { originalTextLength: 0, dictionaryEntries: [] }; + } + return { + originalTextLength: 2, + dictionaryEntries: [ + { + headwords: [ + { + term: 'カズ', + reading: 'かず', + sources: [{ originalText: 'カズ', isPrimary: true, matchType: 'exact' }], + }, + ], + definitions: [ + { + dictionary: 'SubMiner Character Dictionary', + dictionaryAlias: 'SubMiner Character Dictionary', + entries: [ + { + type: 'structured-content', + content: { + tag: 'img', + path: 'img/m115230-c9.png', + alt: 'Kaz', + }, + }, + ], + }, + ], + }, + ], + }; + }); + + assert.deepEqual(result, []); +}); + +test('requestYomitanScanTokens accepts SubMiner character entries with structured-content media data', async () => { + let scannerScript = ''; + const deps = createDeps(async (script) => { + if (script.includes('termsFind')) { + scannerScript = script; + return []; + } + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + }, + }, + ], + }; + } + return null; + }); + + await requestYomitanScanTokens( + 'アクア', + deps, + { error: () => undefined }, + { includeNameMatchMetadata: true, currentCharacterDictionaryMediaId: 21699 }, + ); + + const result = await runInjectedYomitanScript(scannerScript, (action, params) => { + if (action !== 'termsFind') { + throw new Error(`unexpected action: ${action}`); + } + const text = (params as { text?: string } | undefined)?.text; + if (text !== 'アクア') { + return { originalTextLength: 0, dictionaryEntries: [] }; + } + return { + originalTextLength: 3, + dictionaryEntries: [ + { + headwords: [ + { + term: 'アクア', + reading: 'あくあ', + sources: [{ originalText: 'アクア', isPrimary: true, matchType: 'exact' }], + }, + ], + definitions: [ + { + dictionary: 'SubMiner Character Dictionary', + dictionaryAlias: 'SubMiner Character Dictionary', + entries: [ + { + type: 'structured-content', + content: { + tag: 'div', + data: { subminerMediaId: '21699' }, + content: [ + { + tag: 'img', + path: 'img/m115230-c1.png', + alt: 'アクア', + }, + ], + }, + }, + ], + }, + ], + }, + ], + }; + }); + + assert.equal(Array.isArray(result), true); + assert.equal((result as Array<{ surface?: string }>)[0]?.surface, 'アクア'); + assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true); +}); + test('requestYomitanScanTokens preserves matched headword word classes', async () => { let scannerScript = ''; const deps = createDeps(async (script) => { diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index c5fd0835..f4aecbec 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -1106,11 +1106,85 @@ const YOMITAN_SCANNING_HELPERS = String.raw` } return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary")); } - const exactPrimaryMatches = collectExactHeadwordMatches(dictionaryEntries, token, true); + function parseSubMinerMediaIdFromString(value) { + const imageMatch = value.match(/\bimg\/m(\d+)-/i); + if (imageMatch) { + const parsed = Number.parseInt(imageMatch[1], 10); + if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; } + } + const titleMatch = value.match(/SubMiner Character Dictionary[^\d]*(?:AniList\s*)?(\d+)/i); + if (titleMatch) { + const parsed = Number.parseInt(titleMatch[1], 10); + if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; } + } + return null; + } + function parseSubMinerMediaIdCandidate(value) { + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) { + return value; + } + if (typeof value === 'string' && /^\d+$/.test(value.trim())) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isSafeInteger(parsed) && parsed > 0) { return parsed; } + } + return null; + } + function collectSubMinerMediaIds(value, target) { + if (typeof value === 'string') { + const parsed = parseSubMinerMediaIdFromString(value); + if (parsed !== null) { target.add(parsed); } + return; + } + if (!value || typeof value !== 'object') { + return; + } + if (Array.isArray(value)) { + for (const item of value) { collectSubMinerMediaIds(item, target); } + return; + } + const mediaIdCandidates = [ + value.subminerMediaId, + value.subMinerMediaId, + value.characterDictionaryMediaId, + value.data?.subminerMediaId, + value.data?.subMinerMediaId, + value.data?.characterDictionaryMediaId + ]; + for (const candidate of mediaIdCandidates) { + const parsed = parseSubMinerMediaIdCandidate(candidate); + if (parsed !== null) { target.add(parsed); } + } + for (const child of Object.values(value)) { + collectSubMinerMediaIds(child, target); + } + } + function getSubMinerMediaIds(entry) { + const mediaIds = new Set(); + collectSubMinerMediaIds(entry, mediaIds); + return mediaIds; + } + function isCurrentMediaNameDictionaryEntry(entry) { + if (!isNameDictionaryEntry(entry)) { + return false; + } + if (currentCharacterDictionaryMediaId === null) { + return true; + } + const mediaIds = getSubMinerMediaIds(entry); + return mediaIds.size === 0 || mediaIds.has(currentCharacterDictionaryMediaId); + } + const currentMediaDictionaryEntries = + currentCharacterDictionaryMediaId === null + ? (dictionaryEntries || []) + : (dictionaryEntries || []).filter((entry) => { + if (!isNameDictionaryEntry(entry)) { return true; } + return isCurrentMediaNameDictionaryEntry(entry); + }); + const exactPrimaryMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, true); let matchedNameDictionary = false; if (includeNameMatchMetadata) { - for (const dictionaryEntry of dictionaryEntries || []) { - if (!isNameDictionaryEntry(dictionaryEntry)) { continue; } + for (const dictionaryEntry of currentMediaDictionaryEntries || []) { + if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; } for (const match of exactPrimaryMatches) { if (match.dictionaryEntry !== dictionaryEntry) { continue; } matchedNameDictionary = true; @@ -1121,13 +1195,14 @@ const YOMITAN_SCANNING_HELPERS = String.raw` } const preferredMatch = exactPrimaryMatches[0]; if (preferredMatch) { - const exactFrequencyMatches = collectExactHeadwordMatches(dictionaryEntries, token, false) + const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false) .filter((match) => sameHeadword(match, preferredMatch)); return { term: preferredMatch.headword.term, reading: preferredMatch.headword.reading, wordClasses: normalizeWordClasses(preferredMatch.headword), - isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry), + isNameMatch: + matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry), frequencyRank: getBestFrequencyRankForMatches( exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches, dictionaryPriorityByName, @@ -1144,6 +1219,7 @@ function buildYomitanScanningScript( profileIndex: number, scanLength: number, includeNameMatchMetadata: boolean, + currentCharacterDictionaryMediaId: number | null, dictionaryPriorityByName: Record, dictionaryFrequencyModeByName: Partial>, ): string { @@ -1169,6 +1245,11 @@ function buildYomitanScanningScript( }); ${YOMITAN_SCANNING_HELPERS} const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'}; + const currentCharacterDictionaryMediaId = ${ + currentCharacterDictionaryMediaId !== null + ? String(currentCharacterDictionaryMediaId) + : 'null' + }; const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)}; const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)}; const text = ${JSON.stringify(text)}; @@ -1320,6 +1401,7 @@ export async function requestYomitanScanTokens( logger: LoggerLike, options?: { includeNameMatchMetadata?: boolean; + currentCharacterDictionaryMediaId?: number | null; }, ): Promise { const yomitanExt = deps.getYomitanExt(); @@ -1355,6 +1437,11 @@ export async function requestYomitanScanTokens( profileIndex, scanLength, options?.includeNameMatchMetadata === true, + typeof options?.currentCharacterDictionaryMediaId === 'number' && + Number.isFinite(options.currentCharacterDictionaryMediaId) && + options.currentCharacterDictionaryMediaId > 0 + ? Math.floor(options.currentCharacterDictionaryMediaId) + : null, metadata?.dictionaryPriorityByName ?? {}, metadata?.dictionaryFrequencyModeByName ?? {}, ), diff --git a/src/core/utils/shortcut-config.test.ts b/src/core/utils/shortcut-config.test.ts index c9ed98a6..b0264d81 100644 --- a/src/core/utils/shortcut-config.test.ts +++ b/src/core/utils/shortcut-config.test.ts @@ -66,7 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { shortcuts: { mineSentence: 'KeyQ', openRuntimeOptions: 'Digit9', - openCharacterDictionary: 'Ctrl+Shift+KeyA', + openCharacterDictionaryManager: 'Ctrl+KeyD', }, }; @@ -74,7 +74,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => { assert.equal(resolved.mineSentence, 'Q'); assert.equal(resolved.openRuntimeOptions, '9'); - assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A'); + assert.equal(resolved.openCharacterDictionaryManager, 'Ctrl+D'); }); test('preserves null shortcut overrides so defaults can be disabled', () => { diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index dc090cd9..816d2cca 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -12,7 +12,7 @@ export interface ConfiguredShortcuts { multiCopyTimeoutMs: number; toggleSecondarySub: string | null | undefined; markAudioCard: string | null | undefined; - openCharacterDictionary: string | null | undefined; + openCharacterDictionaryManager: string | null | undefined; openRuntimeOptions: string | null | undefined; openJimaku: string | null | undefined; openSessionHelp: string | null | undefined; @@ -58,7 +58,9 @@ export function resolveConfiguredShortcuts( config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000, toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')), markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')), - openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')), + openCharacterDictionaryManager: normalizeShortcut( + shortcutValue('openCharacterDictionaryManager'), + ), openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')), openJimaku: normalizeShortcut(shortcutValue('openJimaku')), openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')), diff --git a/src/main.ts b/src/main.ts index 1a94b4a9..a7a8d0e5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -500,7 +500,10 @@ import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './mai import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; -import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open'; +import { + openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime, + openCharacterDictionaryModal as openCharacterDictionaryModalRuntime, +} from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; @@ -520,7 +523,13 @@ import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats- import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime'; import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime'; import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup'; -import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync'; +import { + createCharacterDictionaryAutoSyncRuntimeService, + getCharacterDictionaryManagerSnapshot, + moveCharacterDictionaryManagedEntry, + removeCharacterDictionaryManagedEntry, + replaceCharacterDictionaryManagedEntry, +} from './main/runtime/character-dictionary-auto-sync'; import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; @@ -838,7 +847,15 @@ const bootServices = createMainBootServices({ createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode), createLogger, createMainRuntimeRegistry, - createOverlayManager, + createOverlayManager: () => + createOverlayManager({ + shouldPromoteWindowOnBoundsUpdate: (window) => + !shouldSuppressVisibleOverlayRaiseForSeparateWindow({ + window, + mainWindow: overlayManager.getMainWindow(), + separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow], + }), + }), createOverlayModalInputState, createOverlayContentMeasurementStore: ({ logger }) => { const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({ @@ -1899,6 +1916,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( openCharacterDictionary: () => { openCharacterDictionaryOverlay(); }, + openCharacterDictionaryManager: () => { + openCharacterDictionaryManagerOverlay(); + }, openJimaku: () => { openJimakuOverlay(); }, @@ -2191,10 +2211,6 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({ logWarn: (message) => logger.warn(message), }); -const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({ - userDataPath: USER_DATA_PATH, -}); - const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({ userDataPath: USER_DATA_PATH, getConfig: () => { @@ -2308,6 +2324,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt }, }); +const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({ + userDataPath: USER_DATA_PATH, + getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(), +}); + const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( createBuildOverlayVisibilityRuntimeMainDepsHandler({ getMainWindow: () => overlayManager.getMainWindow(), @@ -2918,6 +2939,14 @@ function openCharacterDictionaryOverlay(): void { ); } +function openCharacterDictionaryManagerOverlay(): void { + openOverlayHostedModalWithOsd( + openCharacterDictionaryManagerModalRuntime, + 'Character dictionary manager unavailable.', + 'Failed to open character dictionary manager.', + ); +} + function openControllerSelectOverlay(): void { openOverlayHostedModalWithOsd( openControllerSelectModalRuntime, @@ -4740,6 +4769,8 @@ const { getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled, getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term), + getCurrentCharacterDictionaryMediaId: () => + characterDictionaryAutoSyncRuntime.getCurrentMediaId(), getFrequencyDictionaryEnabled: () => getRuntimeBooleanOption( 'subtitle.annotation.frequency', @@ -5727,6 +5758,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openJimaku: () => openJimakuOverlay(), openSessionHelp: () => openSessionHelpOverlay(), openCharacterDictionary: () => openCharacterDictionaryOverlay(), + openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openControllerSelect: () => openControllerSelectOverlay(), openControllerDebug: () => openControllerDebugOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), @@ -5990,8 +6022,31 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }), getCharacterDictionarySelection: (searchTitle?: string) => characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle), - setCharacterDictionarySelection: async (mediaId: number) => - applyCharacterDictionarySelection( + setCharacterDictionarySelection: async ( + mediaId: number, + replaceManagedMediaId?: number, + mediaTitle?: string, + ) => { + if (replaceManagedMediaId !== undefined && mediaTitle) { + const result = replaceCharacterDictionaryManagedEntry( + USER_DATA_PATH, + replaceManagedMediaId, + { + mediaId, + mediaTitle, + }, + ); + if (result.ok && result.rebuildRequired) { + try { + await characterDictionaryAutoSyncRuntime.runSyncNow(); + characterDictionaryImageLookup.invalidate(); + } catch (error) { + logger.warn('Failed to rebuild character dictionary after manager override:', error); + } + } + return result; + } + return await applyCharacterDictionarySelection( { mediaId }, { setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request), @@ -5999,7 +6054,46 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(), warn: (message, error) => logger.warn(message, error), }, + ); + }, + getCharacterDictionaryManagerSnapshot: async () => + getCharacterDictionaryManagerSnapshot( + USER_DATA_PATH, + characterDictionaryAutoSyncRuntime.getCurrentMediaId(), ), + removeCharacterDictionaryManagedEntry: async (mediaId: number) => { + const result = removeCharacterDictionaryManagedEntry( + USER_DATA_PATH, + mediaId, + characterDictionaryAutoSyncRuntime.getCurrentMediaId(), + ); + if (result.ok && result.rebuildRequired) { + try { + await characterDictionaryAutoSyncRuntime.runSyncNow(); + characterDictionaryImageLookup.invalidate(); + } catch (error) { + logger.warn('Failed to rebuild character dictionary after manager removal:', error); + } + } + return result; + }, + moveCharacterDictionaryManagedEntry: async (mediaId: number, direction: 1 | -1) => { + const result = moveCharacterDictionaryManagedEntry( + USER_DATA_PATH, + mediaId, + direction, + characterDictionaryAutoSyncRuntime.getCurrentMediaId(), + ); + if (result.ok && result.rebuildRequired) { + try { + await characterDictionaryAutoSyncRuntime.runSyncNow(); + characterDictionaryImageLookup.invalidate(); + } catch (error) { + logger.warn('Failed to rebuild character dictionary after manager reorder:', error); + } + } + return result; + }, appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), ...playlistBrowserMainDeps, getImmersionTracker: () => appState.immersionTracker, diff --git a/src/main/character-dictionary-runtime/build.test.ts b/src/main/character-dictionary-runtime/build.test.ts index 974e6023..fabba155 100644 --- a/src/main/character-dictionary-runtime/build.test.ts +++ b/src/main/character-dictionary-runtime/build.test.ts @@ -119,3 +119,48 @@ test('buildSnapshotFromCharacters shows Japanese aliases without adding romanize assert.equal(terms.includes('アクア'), true); assert.equal(terms.includes('阿久亜'), true); }); + +test('buildSnapshotFromCharacters stores media id in Yomitan structured-content data', () => { + const character: CharacterRecord = { + id: 1, + role: 'main', + firstNameHint: '', + fullName: 'Aqua', + lastNameHint: '', + nativeName: 'アクア', + alternativeNames: [], + bloodType: '', + birthday: null, + description: '', + imageUrl: null, + age: '', + sex: '', + voiceActors: [], + }; + + const snapshot = buildSnapshotFromCharacters( + 21699, + "KONOSUBA -God's blessing on this wonderful world! 2", + [character], + new Map(), + new Map(), + 1_700_000_000_000, + () => false, + ); + const aquaEntry = snapshot.termEntries.find(([term]) => term === 'アクア'); + assert.ok(aquaEntry); + const glossaryEntry = aquaEntry[5][0] as { + content: { + data?: Record; + content: Array>; + }; + }; + + assert.equal(glossaryEntry.content.data?.subminerMediaId, '21699'); + assert.equal( + glossaryEntry.content.content.some((node) => + Object.prototype.hasOwnProperty.call(node, 'subminerMediaId'), + ), + false, + ); +}); diff --git a/src/main/character-dictionary-runtime/constants.ts b/src/main/character-dictionary-runtime/constants.ts index c214d9c1..0729ad44 100644 --- a/src/main/character-dictionary-runtime/constants.ts +++ b/src/main/character-dictionary-runtime/constants.ts @@ -1,7 +1,7 @@ export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'; export const ANILIST_REQUEST_DELAY_MS = 2000; export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250; -export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16; +export const CHARACTER_DICTIONARY_FORMAT_VERSION = 17; export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary'; export const HONORIFIC_SUFFIXES = [ diff --git a/src/main/character-dictionary-runtime/glossary.ts b/src/main/character-dictionary-runtime/glossary.ts index c02197ca..ebb0f008 100644 --- a/src/main/character-dictionary-runtime/glossary.ts +++ b/src/main/character-dictionary-runtime/glossary.ts @@ -146,6 +146,7 @@ function buildKnownNamesBlock(nameTerms: string[]): Record | nu export function createDefinitionGlossary( character: CharacterRecord, + mediaId: number, mediaTitle: string, imagePath: string | null, vaImagePaths: Map, @@ -258,7 +259,7 @@ export function createDefinitionGlossary( return [ { type: 'structured-content', - content: { tag: 'div', content }, + content: { tag: 'div', data: { subminerMediaId: String(mediaId) }, content }, }, ]; } diff --git a/src/main/character-dictionary-runtime/image-lookup.test.ts b/src/main/character-dictionary-runtime/image-lookup.test.ts index 8a05b95b..83049f2e 100644 --- a/src/main/character-dictionary-runtime/image-lookup.test.ts +++ b/src/main/character-dictionary-runtime/image-lookup.test.ts @@ -5,7 +5,10 @@ import * as path from 'path'; import test from 'node:test'; import { getSnapshotPath, writeSnapshot } from './cache'; import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; -import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup'; +import { + buildCharacterNameImageIndexFromSnapshots, + createCharacterDictionaryImageLookup, +} from './image-lookup'; import type { CharacterDictionarySnapshot } from './types'; const PNG_1X1_BASE64 = @@ -119,3 +122,96 @@ test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes bef assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`); }); + +test('createCharacterDictionaryImageLookup can scope duplicate names to the current media', () => { + const outputDir = makeTempDir(); + const towerSnapshot: CharacterDictionarySnapshot = { + formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, + mediaId: 115230, + mediaTitle: 'Tower of God', + entryCount: 1, + updatedAt: 1_700_000_000_000, + termEntries: [ + [ + 'カズ', + 'かず', + 'name primary', + '', + 75, + [ + { + type: 'structured-content', + content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' }, + }, + ], + 0, + '', + ], + ], + images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }], + }; + const konosubaSnapshot: CharacterDictionarySnapshot = { + ...towerSnapshot, + mediaId: 21202, + mediaTitle: 'KonoSuba', + termEntries: [ + [ + 'カズ', + 'かず', + 'name primary', + '', + 75, + [ + { + type: 'structured-content', + content: { tag: 'img', path: 'img/m21202-c2.png', alt: 'Kazuma' }, + }, + ], + 0, + '', + ], + ], + images: [{ path: 'img/m21202-c2.png', dataBase64: 'KONOSUBA' }], + }; + writeSnapshot(getSnapshotPath(outputDir, towerSnapshot.mediaId), towerSnapshot); + writeSnapshot(getSnapshotPath(outputDir, konosubaSnapshot.mediaId), konosubaSnapshot); + + const lookup = createCharacterDictionaryImageLookup({ outputDir }); + + assert.equal(lookup.get('カズ', 21202)?.alt, 'Kazuma'); +}); + +test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', () => { + const outputDir = makeTempDir(); + const snapshot: CharacterDictionarySnapshot = { + formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION, + mediaId: 115230, + mediaTitle: 'Tower of God', + entryCount: 1, + updatedAt: 1_700_000_000_000, + termEntries: [ + [ + 'カズ', + 'かず', + 'name primary', + '', + 75, + [ + { + type: 'structured-content', + content: { tag: 'img', path: 'img/m115230-c1.png', alt: 'Kaz' }, + }, + ], + 0, + '', + ], + ], + images: [{ path: 'img/m115230-c1.png', dataBase64: 'TOWER' }], + }; + writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot); + + const lookup = createCharacterDictionaryImageLookup({ outputDir }); + + assert.equal(lookup.get('カズ', 21202), null); + assert.equal(lookup.get('カズ')?.alt, 'Kaz'); +}); diff --git a/src/main/character-dictionary-runtime/image-lookup.ts b/src/main/character-dictionary-runtime/image-lookup.ts index 42432365..5bf89c79 100644 --- a/src/main/character-dictionary-runtime/image-lookup.ts +++ b/src/main/character-dictionary-runtime/image-lookup.ts @@ -23,6 +23,14 @@ function normalizeLookupTerm(term: string): string { return term.trim(); } +function normalizeLookupMediaId(mediaId: unknown): number | null { + if (typeof mediaId !== 'number' || !Number.isFinite(mediaId)) { + return null; + } + const normalized = Math.floor(mediaId); + return normalized > 0 ? normalized : null; +} + function getSnapshotsDir(outputDir: string): string { return path.join(outputDir, 'snapshots'); } @@ -209,8 +217,9 @@ export function buildCharacterNameImageIndexFromSnapshots( export function createCharacterDictionaryImageLookup(deps: { userDataPath?: string; outputDir?: string; + getCurrentMediaId?: () => number | null | undefined; }): { - get: (term: string) => CharacterNameImage | null; + get: (term: string, mediaId?: number | null) => CharacterNameImage | null; invalidate: () => void; } { const outputDir = @@ -218,10 +227,12 @@ export function createCharacterDictionaryImageLookup(deps: { (deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : ''); let signature: string | null = null; let index = new Map(); + let indexByMediaId = new Map>(); function refreshIfNeeded(): void { if (!outputDir) { index = new Map(); + indexByMediaId = new Map>(); signature = ''; return; } @@ -230,16 +241,29 @@ export function createCharacterDictionaryImageLookup(deps: { return; } signature = nextSignature; - index = buildCharacterNameImageIndexFromSnapshots(outputDir); + index = new Map(); + indexByMediaId = new Map>(); + for (const snapshot of readCachedSnapshots(outputDir)) { + appendSnapshotImages(index, snapshot); + const mediaIndex = new Map(); + appendSnapshotImages(mediaIndex, snapshot); + if (mediaIndex.size > 0) { + indexByMediaId.set(snapshot.mediaId, mediaIndex); + } + } } return { - get(term: string): CharacterNameImage | null { + get(term: string, mediaId?: number | null): CharacterNameImage | null { const normalizedTerm = normalizeLookupTerm(term); if (!normalizedTerm) { return null; } refreshIfNeeded(); + const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null); + if (scopedMediaId !== null) { + return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null; + } return index.get(normalizedTerm) ?? null; }, invalidate(): void { diff --git a/src/main/character-dictionary-runtime/snapshot.ts b/src/main/character-dictionary-runtime/snapshot.ts index b4d2f62a..775c4d51 100644 --- a/src/main/character-dictionary-runtime/snapshot.ts +++ b/src/main/character-dictionary-runtime/snapshot.ts @@ -48,6 +48,7 @@ export function buildSnapshotFromCharacters( const candidateTerms = buildNameTerms(character); const glossary = createDefinitionGlossary( character, + mediaId, mediaTitle, imagePath, vaImagePaths, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 3928deff..d1959a62 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -98,6 +98,9 @@ export interface MainIpcRuntimeServiceDepsParams { runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark']; getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection']; setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection']; + getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot']; + removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry']; + moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot']; appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile']; @@ -272,6 +275,9 @@ export function createMainIpcRuntimeServiceDeps( runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark, getCharacterDictionarySelection: params.getCharacterDictionarySelection, setCharacterDictionarySelection: params.setCharacterDictionarySelection, + getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot, + removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry, + moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot, appendPlaylistBrowserFile: params.appendPlaylistBrowserFile, diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 1e1d7d7a..52ac5aa1 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -20,6 +20,7 @@ export interface OverlayShortcutRuntimeServiceInput { showMpvOsd: (text: string) => void; openRuntimeOptionsPalette: () => void; openCharacterDictionary: () => void; + openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => Promise; copySubtitleMultiple: (timeoutMs: number) => void; @@ -53,6 +54,9 @@ export function createOverlayShortcutsRuntimeService( openCharacterDictionary: () => { input.openCharacterDictionary(); }, + openCharacterDictionaryManager: () => { + input.openCharacterDictionaryManager(); + }, openJimaku: () => { input.openJimaku(); }, diff --git a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts index d252957c..3e3708e1 100644 --- a/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync-notifications.test.ts @@ -152,8 +152,5 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot }, }); - assert.deepEqual(calls, [ - 'sequencer:importing:importing', - 'desktop:SubMiner:importing', - ]); + assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']); }); diff --git a/src/main/runtime/character-dictionary-auto-sync.test.ts b/src/main/runtime/character-dictionary-auto-sync.test.ts index e6722568..f6302e0e 100644 --- a/src/main/runtime/character-dictionary-auto-sync.test.ts +++ b/src/main/runtime/character-dictionary-auto-sync.test.ts @@ -3,7 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import test from 'node:test'; -import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync'; +import { + createCharacterDictionaryAutoSyncRuntimeService, + getCharacterDictionaryManagerSnapshot, + moveCharacterDictionaryManagedEntry, + removeCharacterDictionaryManagedEntry, +} from './character-dictionary-auto-sync'; function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-')); @@ -17,6 +22,94 @@ function createDeferred(): { promise: Promise; resolve: (value: T) => void return { promise, resolve }; } +test('character dictionary manager snapshots, reorders, and removes MRU entries', () => { + const userDataPath = makeTempDir(); + const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync( + statePath, + JSON.stringify( + { + activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God', '130298 - Eminence'], + mergedRevision: 'rev-1', + mergedDictionaryTitle: 'SubMiner Character Dictionary', + }, + null, + 2, + ), + 'utf8', + ); + + assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath).entries, [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, + { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false }, + { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, + ]); + + assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 130298, -1), { + ok: true, + entries: [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, + { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, + { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: false }, + ], + rebuildRequired: true, + }); + const reorderedState = JSON.parse(fs.readFileSync(statePath, 'utf8')) as { + mergedRevision: string | null; + }; + assert.equal(reorderedState.mergedRevision, null); + + assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230), { + ok: true, + entries: [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true }, + { mediaId: 130298, label: '130298 - Eminence', title: 'Eminence', current: false }, + ], + rebuildRequired: true, + }); +}); + +test('character dictionary manager protects the actual current media after LRU reorder', () => { + const userDataPath = makeTempDir(); + const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync( + statePath, + JSON.stringify( + { + activeMediaIds: ['21202 - KonoSuba', '115230 - Tower of God'], + mergedRevision: 'rev-1', + mergedDictionaryTitle: 'SubMiner Character Dictionary', + }, + null, + 2, + ), + 'utf8', + ); + + assert.deepEqual(getCharacterDictionaryManagerSnapshot(userDataPath, 115230).entries, [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, + { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, + ]); + assert.deepEqual(moveCharacterDictionaryManagedEntry(userDataPath, 115230, -1, 115230), { + ok: false, + message: 'The current anime stays anchored while you are watching it.', + entries: [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, + { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, + ], + }); + assert.deepEqual(removeCharacterDictionaryManagedEntry(userDataPath, 115230, 115230), { + ok: false, + message: 'The current anime stays loaded while you are watching it.', + entries: [ + { mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: false }, + { mediaId: 115230, label: '115230 - Tower of God', title: 'Tower of God', current: true }, + ], + }); +}); + test('auto sync imports merged dictionary and persists MRU state', async () => { const userDataPath = makeTempDir(); const imported: string[] = []; diff --git a/src/main/runtime/character-dictionary-auto-sync.ts b/src/main/runtime/character-dictionary-auto-sync.ts index 08474893..a51e8d91 100644 --- a/src/main/runtime/character-dictionary-auto-sync.ts +++ b/src/main/runtime/character-dictionary-auto-sync.ts @@ -24,6 +24,21 @@ type AutoSyncDictionaryInfo = { revision?: string | number; }; +export interface CharacterDictionaryManagerEntry { + mediaId: number; + label: string; + title: string; + current: boolean; +} + +export interface CharacterDictionaryManagerSnapshot { + entries: CharacterDictionaryManagerEntry[]; +} + +export type CharacterDictionaryManagerMutationResult = + | (CharacterDictionaryManagerSnapshot & { ok: true; rebuildRequired?: boolean }) + | { ok: false; message: string; entries: CharacterDictionaryManagerEntry[] }; + export interface CharacterDictionaryAutoSyncConfig { enabled: boolean; maxLoaded: number; @@ -154,6 +169,167 @@ function writeAutoSyncState(statePath: string, state: AutoSyncState): void { fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8'); } +function getAutoSyncStatePath(userDataPath: string): string { + return path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json'); +} + +function parseActiveMediaTitle(entry: AutoSyncMediaEntry): string { + const prefix = `${entry.mediaId} - `; + if (entry.label.startsWith(prefix)) { + return entry.label.slice(prefix.length).trim(); + } + return entry.label === String(entry.mediaId) ? '' : entry.label.trim(); +} + +function resolveCurrentManagerMediaId( + state: AutoSyncState, + currentMediaId?: number | null, +): number | null { + const normalizedCurrentMediaId = + typeof currentMediaId === 'number' ? normalizeMediaId(currentMediaId) : null; + if (normalizedCurrentMediaId !== null) return normalizedCurrentMediaId; + return state.activeMediaIds[0]?.mediaId ?? null; +} + +function toManagerEntries( + state: AutoSyncState, + currentMediaId?: number | null, +): CharacterDictionaryManagerEntry[] { + const resolvedCurrentMediaId = resolveCurrentManagerMediaId(state, currentMediaId); + return state.activeMediaIds.map((entry, index) => ({ + mediaId: entry.mediaId, + label: entry.label, + title: parseActiveMediaTitle(entry), + current: + resolvedCurrentMediaId !== null ? entry.mediaId === resolvedCurrentMediaId : index === 0, + })); +} + +export function getCharacterDictionaryManagerSnapshot( + userDataPath: string, + currentMediaId?: number | null, +): CharacterDictionaryManagerSnapshot { + return { + entries: toManagerEntries( + readAutoSyncState(getAutoSyncStatePath(userDataPath)), + currentMediaId, + ), + }; +} + +export function moveCharacterDictionaryManagedEntry( + userDataPath: string, + mediaId: number, + direction: 1 | -1, + currentMediaId?: number | null, +): CharacterDictionaryManagerMutationResult { + const statePath = getAutoSyncStatePath(userDataPath); + const state = readAutoSyncState(statePath); + const managerEntries = toManagerEntries(state, currentMediaId); + const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); + if (index < 0) { + return { + ok: false, + message: 'Character dictionary entry not found.', + entries: managerEntries, + }; + } + if (managerEntries[index]?.current) { + return { + ok: false, + message: 'The current anime stays anchored while you are watching it.', + entries: managerEntries, + }; + } + const targetIndex = Math.min(state.activeMediaIds.length - 1, Math.max(0, index + direction)); + if (targetIndex === index) { + return { ok: true, entries: managerEntries }; + } + const nextActiveMediaIds = [...state.activeMediaIds]; + const [entry] = nextActiveMediaIds.splice(index, 1); + if (entry) { + nextActiveMediaIds.splice(targetIndex, 0, entry); + } + const nextState = { ...state, activeMediaIds: nextActiveMediaIds, mergedRevision: null }; + writeAutoSyncState(statePath, nextState); + return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true }; +} + +export function removeCharacterDictionaryManagedEntry( + userDataPath: string, + mediaId: number, + currentMediaId?: number | null, +): CharacterDictionaryManagerMutationResult { + const statePath = getAutoSyncStatePath(userDataPath); + const state = readAutoSyncState(statePath); + const managerEntries = toManagerEntries(state, currentMediaId); + const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); + if (index < 0) { + return { + ok: false, + message: 'Character dictionary entry not found.', + entries: managerEntries, + }; + } + if (managerEntries[index]?.current) { + return { + ok: false, + message: 'The current anime stays loaded while you are watching it.', + entries: managerEntries, + }; + } + const nextState = { + ...state, + activeMediaIds: state.activeMediaIds.filter((entry) => entry.mediaId !== mediaId), + mergedRevision: null, + }; + writeAutoSyncState(statePath, nextState); + return { ok: true, entries: toManagerEntries(nextState, currentMediaId), rebuildRequired: true }; +} + +export function replaceCharacterDictionaryManagedEntry( + userDataPath: string, + mediaId: number, + replacement: { mediaId: number; mediaTitle: string }, +): CharacterDictionaryManagerMutationResult { + const statePath = getAutoSyncStatePath(userDataPath); + const state = readAutoSyncState(statePath); + const index = state.activeMediaIds.findIndex((entry) => entry.mediaId === mediaId); + if (index < 0) { + return { + ok: false, + message: 'Character dictionary entry not found.', + entries: toManagerEntries(state), + }; + } + const normalizedReplacementMediaId = normalizeMediaId(replacement.mediaId); + const mediaTitle = replacement.mediaTitle.trim(); + if (normalizedReplacementMediaId === null || !mediaTitle) { + return { + ok: false, + message: 'Invalid replacement AniList media.', + entries: toManagerEntries(state), + }; + } + const replacementEntry = { + mediaId: normalizedReplacementMediaId, + label: buildActiveMediaLabel(normalizedReplacementMediaId, mediaTitle), + }; + const nextActiveMediaIds = state.activeMediaIds + .map((entry, entryIndex) => (entryIndex === index ? replacementEntry : entry)) + .filter( + (entry, entryIndex, entries) => + entries.findIndex((candidate) => candidate.mediaId === entry.mediaId) === entryIndex, + ); + const nextState = { + ...state, + activeMediaIds: nextActiveMediaIds, + mergedRevision: null, + }; + writeAutoSyncState(statePath, nextState); + return { ok: true, entries: toManagerEntries(nextState), rebuildRequired: true }; +} + function arraysEqual(left: number[], right: number[]): boolean { if (left.length !== right.length) return false; for (let i = 0; i < left.length; i += 1) { @@ -205,9 +381,10 @@ export function createCharacterDictionaryAutoSyncRuntimeService( ): { scheduleSync: () => void; runSyncNow: () => Promise; + getCurrentMediaId: () => number | null; } { const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries'); - const statePath = path.join(dictionariesDir, 'auto-sync-state.json'); + const statePath = getAutoSyncStatePath(deps.userDataPath); const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs)); const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer)); const debounceMs = 800; @@ -216,6 +393,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService( let debounceTimer: ReturnType | null = null; let syncInFlight = false; let runQueued = false; + let activeCurrentMediaId: number | null = null; const withOperationTimeout = async (label: string, promise: Promise): Promise => { let timer: ReturnType | null = null; @@ -238,6 +416,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService( const runSyncOnce = async (): Promise => { const config = deps.getConfig(); if (!config.enabled) { + activeCurrentMediaId = null; return; } @@ -250,6 +429,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService( onChecking: ({ mediaId, mediaTitle }) => { currentMediaId = mediaId; currentMediaTitle = mediaTitle; + activeCurrentMediaId = mediaId; deps.onSyncStatus?.({ phase: 'checking', mediaId, @@ -260,6 +440,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService( onGenerating: ({ mediaId, mediaTitle }) => { currentMediaId = mediaId; currentMediaTitle = mediaTitle; + activeCurrentMediaId = mediaId; deps.onSyncStatus?.({ phase: 'generating', mediaId, @@ -270,6 +451,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService( }); currentMediaId = snapshot.mediaId; currentMediaTitle = snapshot.mediaTitle; + activeCurrentMediaId = snapshot.mediaId; const state = readAutoSyncState(statePath); const staleMediaIds = new Set( (snapshot.staleMediaIds ?? []) @@ -453,5 +635,6 @@ export function createCharacterDictionaryAutoSyncRuntimeService( runSyncNow: async () => { await runSyncOnce(); }, + getCurrentMediaId: () => activeCurrentMediaId, }; } diff --git a/src/main/runtime/character-dictionary-open.ts b/src/main/runtime/character-dictionary-open.ts index 6db72628..e0c2007d 100644 --- a/src/main/runtime/character-dictionary-open.ts +++ b/src/main/runtime/character-dictionary-open.ts @@ -5,7 +5,7 @@ import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted- const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary'; const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500; -export async function openCharacterDictionaryModal(deps: { +async function openCharacterDictionaryModalChannel(deps: { ensureOverlayStartupPrereqs: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void; sendToActiveOverlayWindow: ( @@ -18,6 +18,8 @@ export async function openCharacterDictionaryModal(deps: { ) => boolean; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; logWarn: (message: string) => void; + channel: string; + retryWarning: string; }): Promise { return await retryOverlayModalOpen( { @@ -27,8 +29,7 @@ export async function openCharacterDictionaryModal(deps: { { modal: CHARACTER_DICTIONARY_MODAL, timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS, - retryWarning: - 'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + retryWarning: deps.retryWarning, sendOpen: () => openOverlayHostedModal( { @@ -38,7 +39,7 @@ export async function openCharacterDictionaryModal(deps: { sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, }, { - channel: IPC_CHANNELS.event.characterDictionaryOpen, + channel: deps.channel, modal: CHARACTER_DICTIONARY_MODAL, preferModalWindow: true, }, @@ -46,3 +47,30 @@ export async function openCharacterDictionaryModal(deps: { }, ); } + +type OpenCharacterDictionaryModalDeps = Omit< + Parameters[0], + 'channel' | 'retryWarning' +>; + +export async function openCharacterDictionaryModal( + deps: OpenCharacterDictionaryModalDeps, +): Promise { + return await openCharacterDictionaryModalChannel({ + ...deps, + channel: IPC_CHANNELS.event.characterDictionaryOpen, + retryWarning: + 'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + }); +} + +export async function openCharacterDictionaryManagerModal( + deps: OpenCharacterDictionaryModalDeps, +): Promise { + return await openCharacterDictionaryModalChannel({ + ...deps, + channel: IPC_CHANNELS.event.characterDictionaryManagerOpen, + retryWarning: + 'Character dictionary manager did not acknowledge modal open on first attempt; retrying dedicated modal window.', + }); +} diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index d916540c..53655401 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -16,7 +16,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index 871bcc5f..c7665724 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -20,7 +20,7 @@ function createShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 5000, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts index 59c68cd1..c46b6f6c 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -17,6 +17,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call showMpvOsd: (text) => calls.push(`osd:${text}`), openRuntimeOptionsPalette: () => calls.push('runtime-options'), openCharacterDictionary: () => calls.push('character-dictionary'), + openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openJimaku: () => calls.push('jimaku'), markAudioCard: async () => { calls.push('mark-audio'); @@ -49,6 +50,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call deps.showMpvOsd('x'); deps.openRuntimeOptionsPalette(); deps.openCharacterDictionary(); + deps.openCharacterDictionaryManager(); deps.openJimaku(); await deps.markAudioCard(); deps.copySubtitleMultiple(5000); @@ -66,6 +68,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call 'osd:x', 'runtime-options', 'character-dictionary', + 'character-dictionary-manager', 'jimaku', 'mark-audio', 'copy-multi:5000', diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts index ef227b7d..2cd10262 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -12,6 +12,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler( showMpvOsd: (text: string) => deps.showMpvOsd(text), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openCharacterDictionary: () => deps.openCharacterDictionary(), + openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(), openJimaku: () => deps.openJimaku(), markAudioCard: () => deps.markAudioCard(), copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), diff --git a/src/main/runtime/subtitle-tokenization-main-deps.ts b/src/main/runtime/subtitle-tokenization-main-deps.ts index faf536e9..28209a37 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.ts @@ -6,6 +6,9 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & { getNameMatchEnabled?: NonNullable; getNameMatchImagesEnabled?: NonNullable; getCharacterNameImage?: NonNullable; + getCurrentCharacterDictionaryMediaId?: NonNullable< + TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId'] + >; getFrequencyDictionaryEnabled: NonNullable< TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled'] >; @@ -70,6 +73,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) { getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term), } : {}), + ...(deps.getCurrentCharacterDictionaryMediaId + ? { + getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(), + } + : {}), getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(), getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(), getFrequencyRank: (text: string) => deps.getFrequencyRank(text), diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 6933d16d..ab374918 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -66,8 +66,7 @@ test('build tray template handler wires actions and init guards', () => { openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, - openFirstRunSetupWindow: (force?: boolean) => - calls.push(force ? 'setup-forced' : 'setup'), + openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), @@ -118,8 +117,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => { openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => false, - openFirstRunSetupWindow: (force?: boolean) => - calls.push(force ? 'setup-forced' : 'setup'), + openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index f8273d34..a996fcc4 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -28,8 +28,7 @@ test('tray main deps builders return mapped handlers', () => { openTexthookerInBrowser: () => calls.push('texthooker'), showTexthookerPage: () => true, showFirstRunSetup: () => true, - openFirstRunSetupWindow: (force?: boolean) => - calls.push(force ? 'setup-forced' : 'setup'), + openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'), showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), diff --git a/src/preload.ts b/src/preload.ts index 51a9bd5a..2875b992 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -159,6 +159,9 @@ const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessio const onOpenCharacterDictionaryEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryOpen, ); +const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( + IPC_CHANNELS.event.characterDictionaryManagerOpen, +); const onOpenControllerSelectEvent = createQueuedIpcListener( IPC_CHANNELS.event.controllerSelectOpen, ); @@ -388,6 +391,7 @@ const electronAPI: ElectronAPI = { onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, + onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, @@ -415,8 +419,27 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request), getCharacterDictionarySelection: (searchTitle?: string) => ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle), - setCharacterDictionarySelection: (mediaId: number) => - ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId), + setCharacterDictionarySelection: ( + mediaId: number, + replaceManagedMediaId?: number, + mediaTitle?: string, + ) => + ipcRenderer.invoke( + IPC_CHANNELS.request.setCharacterDictionarySelection, + mediaId, + replaceManagedMediaId, + mediaTitle, + ), + getCharacterDictionaryManagerSnapshot: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionaryManagerSnapshot), + removeCharacterDictionaryManagedEntry: (mediaId: number) => + ipcRenderer.invoke(IPC_CHANNELS.request.removeCharacterDictionaryManagedEntry, mediaId), + moveCharacterDictionaryManagedEntry: (mediaId: number, direction: 1 | -1) => + ipcRenderer.invoke( + IPC_CHANNELS.request.moveCharacterDictionaryManagedEntry, + mediaId, + direction, + ), notifyOverlayModalClosed: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index 879422fd..4ecec61a 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -87,7 +87,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts { multiCopyTimeoutMs: 3000, toggleSecondarySub: null, markAudioCard: null, - openCharacterDictionary: null, + openCharacterDictionaryManager: null, openRuntimeOptions: null, openJimaku: null, openSessionHelp: null, diff --git a/src/renderer/index.html b/src/renderer/index.html index 685f74f6..5b990df4 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -200,29 +200,61 @@