mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
43ebc7d371
|
|||
| 639e331f24 | |||
|
78be72e32f
|
|||
| 3932e53ced | |||
| 097b619d71 |
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Added surname honorific matches for Japanese localized character aliases embedded in AniList alternative names, such as Korean-source characters with Japanese names in parentheses, and refresh cached snapshots so those aliases are regenerated.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: desktop
|
||||||
|
|
||||||
|
- Fixed Hyprland settings windows opening behind the subtitle overlay by promoting SubMiner and Yomitan settings above the overlay without hiding subtitles.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Fixed `subminer app` on Linux so launching the tray app returns control to the terminal immediately instead of waiting for the tray process to exit.
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
"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.
|
"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.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# 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)
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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'`.
|
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.
|
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. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||||
5. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
|
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.
|
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:
|
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.
|
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
|
||||||
|
|
||||||
```bash
|
```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.
|
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
|
## File Structure
|
||||||
|
|
||||||
All character dictionary data lives under `{userData}/character-dictionaries/`:
|
All character dictionary data lives under `{userData}/character-dictionaries/`:
|
||||||
@@ -215,7 +226,7 @@ character-dictionaries/
|
|||||||
m170942-va67890.jpg # Voice actor portrait
|
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:
|
**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.
|
- **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.
|
- **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.
|
- **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 <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 <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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
"mineSentence": "CommandOrControl+S",
|
"mineSentence": "CommandOrControl+S",
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A",
|
"openCharacterDictionaryManager": "CommandOrControl+D",
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
"openSessionHelp": "CommandOrControl+Slash",
|
"openSessionHelp": "CommandOrControl+Slash",
|
||||||
"openControllerSelect": "Alt+C",
|
"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`) |
|
| `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"`) |
|
| `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"`) |
|
| `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"`) |
|
| `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"`) |
|
| `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"`) |
|
| `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+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+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+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+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
"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.
|
"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.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
|
|||||||
+12
-12
@@ -75,17 +75,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `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+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||||
| `` ` `` | Toggle stats overlay | `stats.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`.
|
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
|
## 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
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection
|
|||||||
|
|
||||||
## Character-Name Highlighting
|
## 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:**
|
**How it works:**
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
type AppCommandDeps = {
|
type AppCommandDeps = {
|
||||||
platform: () => NodeJS.Platform;
|
|
||||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||||
launchAppBackgroundDetached: (
|
launchAppBackgroundDetached: (
|
||||||
appPath: string,
|
appPath: string,
|
||||||
@@ -15,7 +14,6 @@ type AppCommandDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const defaultAppCommandDeps: AppCommandDeps = {
|
const defaultAppCommandDeps: AppCommandDeps = {
|
||||||
platform: () => process.platform,
|
|
||||||
runAppCommandWithInherit,
|
runAppCommandWithInherit,
|
||||||
launchAppBackgroundDetached,
|
launchAppBackgroundDetached,
|
||||||
};
|
};
|
||||||
@@ -35,7 +33,7 @@ export function runAppPassthroughCommand(
|
|||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (deps.platform() === 'darwin' && args.appArgs.length === 0) {
|
if (args.appArgs.length === 0) {
|
||||||
deps.launchAppBackgroundDetached(appPath, args.logLevel);
|
deps.launchAppBackgroundDetached(appPath, args.logLevel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,25 @@ test('app command starts default macOS background app detached from launcher', (
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
const handled = runAppPassthroughCommand(context, {
|
const handled = runAppPassthroughCommand(context, {
|
||||||
platform: () => 'darwin',
|
runAppCommandWithInherit: () => {
|
||||||
|
calls.push('attached');
|
||||||
|
},
|
||||||
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app command starts default Linux background app detached from launcher', () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.appPassthrough = true;
|
||||||
|
context.args.appArgs = [];
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = runAppPassthroughCommand(context, {
|
||||||
runAppCommandWithInherit: () => {
|
runAppCommandWithInherit: () => {
|
||||||
calls.push('attached');
|
calls.push('attached');
|
||||||
},
|
},
|
||||||
@@ -197,7 +215,6 @@ test('app command keeps explicit passthrough args attached', () => {
|
|||||||
const detached: string[] = [];
|
const detached: string[] = [];
|
||||||
|
|
||||||
const handled = runAppPassthroughCommand(context, {
|
const handled = runAppPassthroughCommand(context, {
|
||||||
platform: () => 'darwin',
|
|
||||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
forwarded.push(appArgs);
|
forwarded.push(appArgs);
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.15.0-beta.6",
|
"version": "0.15.0-beta.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.15.0-beta.6",
|
"version": "0.15.0-beta.8",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.15.0-beta.6",
|
"version": "0.15.0-beta.8",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -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: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: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: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/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/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/main/character-dictionary-runtime/term-building.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: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: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",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
|
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
|
||||||
|
|
||||||
|
- **Character Dictionary:** A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries. The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess so it can be edited before confirming an override. Lookup entries are scoped to generated Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results.
|
||||||
|
|
||||||
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
||||||
|
|
||||||
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
||||||
@@ -27,8 +29,6 @@
|
|||||||
|
|
||||||
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
||||||
|
|
||||||
- **Character Dictionary:** The in-app AniList title selector now waits for an explicit search rather than triggering automatically. The search box is prefilled from the current filename guess so it can be edited before confirming an override. Results are scoped to generated Japanese name aliases; raw romanized or English aliases no longer appear as separate entries.
|
|
||||||
|
|
||||||
- **Setup:** The bundled mpv runtime plugin readiness card is removed from first-run setup; the legacy mpv plugin removal notice still appears when needed.
|
- **Setup:** The bundled mpv runtime plugin readiness card is removed from first-run setup; the legacy mpv plugin removal notice still appears when needed.
|
||||||
|
|
||||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; opens over fullscreen mpv without switching Spaces; and stays stable when mpv remains frontmost but window geometry temporarily disappears from macOS APIs. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay also stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; opens over fullscreen mpv without switching Spaces; and stays stable when mpv remains frontmost but window geometry temporarily disappears from macOS APIs. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
||||||
|
|
||||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open.
|
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open. Settings windows (SubMiner and Yomitan) now open above the subtitle overlay on Hyprland instead of behind it.
|
||||||
|
|
||||||
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
|
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
|
||||||
|
|
||||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is now suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
|
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
|
||||||
|
|
||||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
|
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running. On Windows, the tray "Open SubMiner Setup" action now correctly opens the setup window after first-run setup is complete.
|
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running. On Windows, the tray "Open SubMiner Setup" action now correctly opens the setup window after first-run setup is complete.
|
||||||
|
|
||||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang. On Windows, managed mpv launches from a background SubMiner instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang. `subminer app` on Linux returns control to the terminal immediately. On Windows, managed mpv launches from a background SubMiner instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
||||||
|
|
||||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ ${B}Mining${R}
|
|||||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
||||||
--open-runtime-options Open runtime options palette
|
--open-runtime-options Open runtime options palette
|
||||||
--open-session-help Open session help modal
|
--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-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
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.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||||
assert.equal(config.discordPresence.enabled, true);
|
assert.equal(config.discordPresence.enabled, true);
|
||||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
multiCopyTimeoutMs: 3000,
|
multiCopyTimeoutMs: 3000,
|
||||||
toggleSecondarySub: 'CommandOrControl+Shift+V',
|
toggleSecondarySub: 'CommandOrControl+Shift+V',
|
||||||
markAudioCard: 'CommandOrControl+Shift+A',
|
markAudioCard: 'CommandOrControl+Shift+A',
|
||||||
openCharacterDictionary: 'CommandOrControl+Alt+A',
|
openCharacterDictionaryManager: 'CommandOrControl+D',
|
||||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||||
openJimaku: 'Ctrl+Shift+J',
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
openSessionHelp: 'CommandOrControl+Slash',
|
openSessionHelp: 'CommandOrControl+Slash',
|
||||||
|
|||||||
@@ -542,10 +542,10 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description: 'Accelerator that marks the last mined card as an audio card.',
|
description: 'Accelerator that marks the last mined card as an audio card.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.openCharacterDictionary',
|
path: 'shortcuts.openCharacterDictionaryManager',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
|
defaultValue: defaultConfig.shortcuts.openCharacterDictionaryManager,
|
||||||
description: 'Accelerator that opens the character dictionary modal.',
|
description: 'Accelerator that opens the character dictionary manager modal.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.openRuntimeOptions',
|
path: 'shortcuts.openRuntimeOptions',
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
'mineSentenceMultiple',
|
'mineSentenceMultiple',
|
||||||
'toggleSecondarySub',
|
'toggleSecondarySub',
|
||||||
'markAudioCard',
|
'markAudioCard',
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionaryManager',
|
||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -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');
|
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', () => {
|
test('settings registry hides removed modal-only fields', () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
'shortcuts.multiCopyTimeoutMs',
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
|
'shortcuts.openCharacterDictionary',
|
||||||
'anilist.characterDictionary.profileScope',
|
'anilist.characterDictionary.profileScope',
|
||||||
'jellyfin.directPlayContainers',
|
'jellyfin.directPlayContainers',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||||
@@ -570,7 +570,7 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
return 'Toggle & Visibility';
|
return 'Toggle & Visibility';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
leaf === 'openCharacterDictionary' ||
|
leaf === 'openCharacterDictionaryManager' ||
|
||||||
leaf === 'openRuntimeOptions' ||
|
leaf === 'openRuntimeOptions' ||
|
||||||
leaf === 'openJimaku' ||
|
leaf === 'openJimaku' ||
|
||||||
leaf === 'openSessionHelp' ||
|
leaf === 'openSessionHelp' ||
|
||||||
|
|||||||
@@ -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', () => {
|
test('handleCliCommand logs AniList status details', () => {
|
||||||
const { deps, calls } = createDeps();
|
const { deps, calls } = createDeps();
|
||||||
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
|
||||||
|
|||||||
@@ -492,8 +492,8 @@ export function handleCliCommand(
|
|||||||
);
|
);
|
||||||
} else if (args.openCharacterDictionary) {
|
} else if (args.openCharacterDictionary) {
|
||||||
dispatchCliSessionAction(
|
dispatchCliSessionAction(
|
||||||
{ actionId: 'openCharacterDictionary' },
|
{ actionId: 'openCharacterDictionaryManager' },
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionaryManager',
|
||||||
'Open character dictionary failed',
|
'Open character dictionary failed',
|
||||||
);
|
);
|
||||||
} else if (args.openControllerSelect) {
|
} else if (args.openControllerSelect) {
|
||||||
|
|||||||
@@ -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<typeof buildHyprlandPlacementDispatches>[0],
|
||||||
|
bounds: Parameters<typeof buildHyprlandPlacementDispatches>[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', () => {
|
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildHyprlandPlacementDispatches({
|
buildHyprlandPlacementDispatches({
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export interface HyprlandPlacementBounds {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HyprlandPlacementDispatchOptions {
|
||||||
|
promote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type ExecFileSync = typeof execFileSync;
|
type ExecFileSync = typeof execFileSync;
|
||||||
|
|
||||||
export function shouldAttemptHyprlandWindowPlacement(
|
export function shouldAttemptHyprlandWindowPlacement(
|
||||||
@@ -64,6 +68,7 @@ export function findHyprlandWindowForPlacement(
|
|||||||
export function buildHyprlandPlacementDispatches(
|
export function buildHyprlandPlacementDispatches(
|
||||||
client: HyprlandPlacementClient,
|
client: HyprlandPlacementClient,
|
||||||
bounds?: HyprlandPlacementBounds | null,
|
bounds?: HyprlandPlacementBounds | null,
|
||||||
|
options: HyprlandPlacementDispatchOptions = {},
|
||||||
): string[][] {
|
): string[][] {
|
||||||
if (!client.address) {
|
if (!client.address) {
|
||||||
return [];
|
return [];
|
||||||
@@ -95,7 +100,9 @@ export function buildHyprlandPlacementDispatches(
|
|||||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
|
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
|
||||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
|
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;
|
return dispatches;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +134,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
|
promote?: boolean;
|
||||||
execFileSync?: ExecFileSync;
|
execFileSync?: ExecFileSync;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||||
@@ -146,7 +154,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds);
|
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, {
|
||||||
|
promote: options.promote,
|
||||||
|
});
|
||||||
for (const args of dispatches) {
|
for (const args of dispatches) {
|
||||||
run('hyprctl', args, { stdio: 'ignore' });
|
run('hyprctl', args, { stdio: 'ignore' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export {
|
|||||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||||
} from './startup';
|
} from './startup';
|
||||||
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
|
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
|
||||||
|
export { promoteSettingsWindowAboveOverlay } from './settings-window-z-order';
|
||||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||||
export {
|
export {
|
||||||
addYomitanNoteViaSearch,
|
addYomitanNoteViaSearch,
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ export interface IpcServiceDeps {
|
|||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
setCharacterDictionarySelection?: (
|
||||||
|
mediaId: number,
|
||||||
|
replaceManagedMediaId?: number,
|
||||||
|
mediaTitle?: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
getCharacterDictionaryManagerSnapshot?: () => Promise<unknown>;
|
||||||
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -224,7 +231,14 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||||
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
setCharacterDictionarySelection?: (
|
||||||
|
mediaId: number,
|
||||||
|
replaceManagedMediaId?: number,
|
||||||
|
mediaTitle?: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
getCharacterDictionaryManagerSnapshot?: () => Promise<unknown>;
|
||||||
|
removeCharacterDictionaryManagedEntry?: (mediaId: number) => Promise<unknown>;
|
||||||
|
moveCharacterDictionaryManagedEntry?: (mediaId: number, direction: 1 | -1) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
|
||||||
@@ -317,6 +331,22 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
selected: { id: 0, title: '', episodes: null },
|
selected: { id: 0, title: '', episodes: null },
|
||||||
staleMediaIds: [],
|
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,
|
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||||
@@ -629,11 +659,21 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
|
|
||||||
ipc.handle(
|
ipc.handle(
|
||||||
IPC_CHANNELS.request.setCharacterDictionarySelection,
|
IPC_CHANNELS.request.setCharacterDictionarySelection,
|
||||||
async (_event, mediaId: unknown) => {
|
async (_event, mediaId: unknown, replaceManagedMediaId: unknown, mediaTitle: unknown) => {
|
||||||
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
|
if (!Number.isSafeInteger(mediaId) || (mediaId as number) <= 0) {
|
||||||
return { ok: false, message: 'Invalid AniList media ID.' };
|
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({
|
Promise.resolve({
|
||||||
ok: false,
|
ok: false,
|
||||||
message: 'Character dictionary selection unavailable.',
|
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, () => {
|
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||||
return deps.appendClipboardVideoToQueue();
|
return deps.appendClipboardVideoToQueue();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }]);
|
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<typeof createOverlayManager>;
|
||||||
|
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', () => {
|
test('runtime-option broadcast still uses expected channel', () => {
|
||||||
const broadcasts: unknown[][] = [];
|
const broadcasts: unknown[][] = [];
|
||||||
broadcastRuntimeOptionsChangedRuntime(
|
broadcastRuntimeOptionsChangedRuntime(
|
||||||
|
|||||||
@@ -16,10 +16,23 @@ export interface OverlayManager {
|
|||||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
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 mainWindow: BrowserWindow | null = null;
|
||||||
let modalWindow: BrowserWindow | null = null;
|
let modalWindow: BrowserWindow | null = null;
|
||||||
let visibleOverlayVisible = false;
|
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 {
|
return {
|
||||||
getMainWindow: () => mainWindow,
|
getMainWindow: () => mainWindow,
|
||||||
@@ -32,10 +45,10 @@ export function createOverlayManager(): OverlayManager {
|
|||||||
},
|
},
|
||||||
getOverlayWindow: () => mainWindow,
|
getOverlayWindow: () => mainWindow,
|
||||||
setOverlayWindowBounds: (geometry) => {
|
setOverlayWindowBounds: (geometry) => {
|
||||||
updateOverlayWindowBounds(geometry, mainWindow);
|
updateWindowBounds(geometry, mainWindow);
|
||||||
},
|
},
|
||||||
setModalWindowBounds: (geometry) => {
|
setModalWindowBounds: (geometry) => {
|
||||||
updateOverlayWindowBounds(geometry, modalWindow);
|
updateWindowBounds(geometry, modalWindow);
|
||||||
},
|
},
|
||||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||||
setVisibleOverlayVisible: (visible) => {
|
setVisibleOverlayVisible: (visible) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
|||||||
multiCopyTimeoutMs: 2500,
|
multiCopyTimeoutMs: 2500,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
@@ -49,6 +49,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
|||||||
openCharacterDictionary: () => {
|
openCharacterDictionary: () => {
|
||||||
calls.push('openCharacterDictionary');
|
calls.push('openCharacterDictionary');
|
||||||
},
|
},
|
||||||
|
openCharacterDictionaryManager: () => {
|
||||||
|
calls.push('openCharacterDictionaryManager');
|
||||||
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
calls.push('openJimaku');
|
calls.push('openJimaku');
|
||||||
},
|
},
|
||||||
@@ -93,6 +96,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers',
|
|||||||
overlayHandlers.copySubtitleMultiple(1111);
|
overlayHandlers.copySubtitleMultiple(1111);
|
||||||
overlayHandlers.toggleSecondarySub();
|
overlayHandlers.toggleSecondarySub();
|
||||||
overlayHandlers.openRuntimeOptions();
|
overlayHandlers.openRuntimeOptions();
|
||||||
|
overlayHandlers.openCharacterDictionaryManager();
|
||||||
overlayHandlers.openJimaku();
|
overlayHandlers.openJimaku();
|
||||||
overlayHandlers.mineSentenceMultiple(2222);
|
overlayHandlers.mineSentenceMultiple(2222);
|
||||||
overlayHandlers.updateLastCardFromClipboard();
|
overlayHandlers.updateLastCardFromClipboard();
|
||||||
@@ -104,6 +108,7 @@ test('createOverlayShortcutRuntimeHandlers dispatches sync and async handlers',
|
|||||||
'copySubtitleMultiple:1111',
|
'copySubtitleMultiple:1111',
|
||||||
'toggleSecondarySub',
|
'toggleSecondarySub',
|
||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
|
'openCharacterDictionaryManager',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
'mineSentenceMultiple:2222',
|
'mineSentenceMultiple:2222',
|
||||||
'updateLastCardFromClipboard',
|
'updateLastCardFromClipboard',
|
||||||
@@ -159,6 +164,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
|
|||||||
{
|
{
|
||||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||||
|
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||||
openJimaku: () => handled.push('openJimaku'),
|
openJimaku: () => handled.push('openJimaku'),
|
||||||
markAudioCard: () => handled.push('markAudioCard'),
|
markAudioCard: () => handled.push('markAudioCard'),
|
||||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||||
@@ -192,6 +198,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
|||||||
{
|
{
|
||||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||||
|
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||||
openJimaku: () => handled.push('openJimaku'),
|
openJimaku: () => handled.push('openJimaku'),
|
||||||
markAudioCard: () => handled.push('markAudioCard'),
|
markAudioCard: () => handled.push('markAudioCard'),
|
||||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||||
@@ -212,6 +219,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
|||||||
{
|
{
|
||||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||||
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
|
||||||
|
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||||
openJimaku: () => handled.push('openJimaku'),
|
openJimaku: () => handled.push('openJimaku'),
|
||||||
markAudioCard: () => handled.push('markAudioCard'),
|
markAudioCard: () => handled.push('markAudioCard'),
|
||||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||||
@@ -249,6 +257,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
|||||||
{
|
{
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
copySubtitleMultiple: () => {},
|
copySubtitleMultiple: () => {},
|
||||||
@@ -285,6 +294,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
|||||||
{
|
{
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
copySubtitleMultiple: () => {},
|
copySubtitleMultiple: () => {},
|
||||||
@@ -315,6 +325,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
|||||||
openCharacterDictionary: () => {
|
openCharacterDictionary: () => {
|
||||||
called = true;
|
called = true;
|
||||||
},
|
},
|
||||||
|
openCharacterDictionaryManager: () => {
|
||||||
|
called = true;
|
||||||
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
called = true;
|
called = true;
|
||||||
},
|
},
|
||||||
@@ -398,6 +411,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
|||||||
toggleSecondarySub: () => {},
|
toggleSecondarySub: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
}),
|
}),
|
||||||
@@ -425,6 +439,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
|
|||||||
toggleSecondarySub: () => {},
|
toggleSecondarySub: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = createLogger('main:overlay-shortcut-handler');
|
|||||||
export interface OverlayShortcutFallbackHandlers {
|
export interface OverlayShortcutFallbackHandlers {
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openCharacterDictionary: () => void;
|
openCharacterDictionary: () => void;
|
||||||
|
openCharacterDictionaryManager: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
markAudioCard: () => void;
|
markAudioCard: () => void;
|
||||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||||
@@ -23,6 +24,7 @@ export interface OverlayShortcutRuntimeDeps {
|
|||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openCharacterDictionary: () => void;
|
openCharacterDictionary: () => void;
|
||||||
|
openCharacterDictionaryManager: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
markAudioCard: () => Promise<void>;
|
markAudioCard: () => Promise<void>;
|
||||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||||
@@ -100,6 +102,9 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
|||||||
openCharacterDictionary: () => {
|
openCharacterDictionary: () => {
|
||||||
deps.openCharacterDictionary();
|
deps.openCharacterDictionary();
|
||||||
},
|
},
|
||||||
|
openCharacterDictionaryManager: () => {
|
||||||
|
deps.openCharacterDictionaryManager();
|
||||||
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
deps.openJimaku();
|
deps.openJimaku();
|
||||||
},
|
},
|
||||||
@@ -108,6 +113,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
|||||||
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
|
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
|
||||||
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
||||||
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
|
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
|
||||||
|
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
|
||||||
openJimaku: overlayHandlers.openJimaku,
|
openJimaku: overlayHandlers.openJimaku,
|
||||||
markAudioCard: overlayHandlers.markAudioCard,
|
markAudioCard: overlayHandlers.markAudioCard,
|
||||||
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
||||||
@@ -141,9 +147,9 @@ export function runOverlayShortcutLocalFallback(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accelerator: shortcuts.openCharacterDictionary,
|
accelerator: shortcuts.openCharacterDictionaryManager,
|
||||||
run: () => {
|
run: () => {
|
||||||
handlers.openCharacterDictionary();
|
handlers.openCharacterDictionaryManager();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
multiCopyTimeoutMs: 2500,
|
multiCopyTimeoutMs: 2500,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
@@ -44,6 +44,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
|
|||||||
toggleSecondarySub: () => {},
|
toggleSecondarySub: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
}),
|
}),
|
||||||
@@ -64,6 +65,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
|||||||
toggleSecondarySub: () => {},
|
toggleSecondarySub: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
}),
|
}),
|
||||||
@@ -86,6 +88,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
|
|||||||
toggleSecondarySub: () => {},
|
toggleSecondarySub: () => {},
|
||||||
markAudioCard: () => {},
|
markAudioCard: () => {},
|
||||||
openCharacterDictionary: () => {},
|
openCharacterDictionary: () => {},
|
||||||
|
openCharacterDictionaryManager: () => {},
|
||||||
openRuntimeOptions: () => {},
|
openRuntimeOptions: () => {},
|
||||||
openJimaku: () => {},
|
openJimaku: () => {},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface OverlayShortcutHandlers {
|
|||||||
toggleSecondarySub: () => void;
|
toggleSecondarySub: () => void;
|
||||||
markAudioCard: () => void;
|
markAudioCard: () => void;
|
||||||
openCharacterDictionary: () => void;
|
openCharacterDictionary: () => void;
|
||||||
|
openCharacterDictionaryManager: () => void;
|
||||||
openRuntimeOptions: () => void;
|
openRuntimeOptions: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
|||||||
'mineSentenceMultiple',
|
'mineSentenceMultiple',
|
||||||
'toggleSecondarySub',
|
'toggleSecondarySub',
|
||||||
'markAudioCard',
|
'markAudioCard',
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionaryManager',
|
||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -51,11 +51,18 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
|||||||
export function updateOverlayWindowBounds(
|
export function updateOverlayWindowBounds(
|
||||||
geometry: WindowGeometry,
|
geometry: WindowGeometry,
|
||||||
window: BrowserWindow | null,
|
window: BrowserWindow | null,
|
||||||
|
options: {
|
||||||
|
promote?: boolean;
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
if (!geometry || !window || window.isDestroyed()) return;
|
if (!geometry || !window || window.isDestroyed()) return;
|
||||||
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
|
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
|
||||||
window.setBounds(bounds);
|
window.setBounds(bounds);
|
||||||
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), bounds });
|
ensureHyprlandWindowFloatingByTitle({
|
||||||
|
title: window.getTitle(),
|
||||||
|
bounds,
|
||||||
|
promote: options.promote,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
|||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
openSessionHelp: () => calls.push('session-help'),
|
openSessionHelp: () => calls.push('session-help'),
|
||||||
openCharacterDictionary: () => calls.push('character-dictionary'),
|
openCharacterDictionary: () => calls.push('character-dictionary'),
|
||||||
|
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
|
||||||
openControllerSelect: () => calls.push('controller-select'),
|
openControllerSelect: () => calls.push('controller-select'),
|
||||||
openControllerDebug: () => calls.push('controller-debug'),
|
openControllerDebug: () => calls.push('controller-debug'),
|
||||||
openJimaku: () => calls.push('jimaku'),
|
openJimaku: () => calls.push('jimaku'),
|
||||||
@@ -77,3 +78,11 @@ test('dispatchSessionAction does not advance playlist when mark watched no-ops',
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['mark-watched']);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface SessionActionExecutorDeps {
|
|||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openSessionHelp: () => void;
|
openSessionHelp: () => void;
|
||||||
openCharacterDictionary: () => void;
|
openCharacterDictionary: () => void;
|
||||||
|
openCharacterDictionaryManager: () => void;
|
||||||
openControllerSelect: () => void;
|
openControllerSelect: () => void;
|
||||||
openControllerDebug: () => void;
|
openControllerDebug: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
@@ -97,7 +98,10 @@ export async function dispatchSessionAction(
|
|||||||
deps.openSessionHelp();
|
deps.openSessionHelp();
|
||||||
return;
|
return;
|
||||||
case 'openCharacterDictionary':
|
case 'openCharacterDictionary':
|
||||||
deps.openCharacterDictionary();
|
deps.openCharacterDictionaryManager();
|
||||||
|
return;
|
||||||
|
case 'openCharacterDictionaryManager':
|
||||||
|
deps.openCharacterDictionaryManager();
|
||||||
return;
|
return;
|
||||||
case 'openControllerSelect':
|
case 'openControllerSelect':
|
||||||
deps.openControllerSelect();
|
deps.openControllerSelect();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
|||||||
multiCopyTimeoutMs: 2500,
|
multiCopyTimeoutMs: 2500,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
@@ -209,6 +209,41 @@ test('compileSessionBindings keeps default replay and next subtitle session acti
|
|||||||
assert.equal(next?.actionId, 'playNextSubtitle');
|
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', () => {
|
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
|
||||||
const expectedSpecialActions: Record<string, string> = {
|
const expectedSpecialActions: Record<string, string> = {
|
||||||
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
|
[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',
|
'mineSentenceMultiple',
|
||||||
'toggleSecondarySub',
|
'toggleSecondarySub',
|
||||||
'markAudioCard',
|
'markAudioCard',
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionaryManager',
|
||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
'openSessionHelp',
|
'openSessionHelp',
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
|||||||
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
|
{ key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' },
|
||||||
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
|
{ key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' },
|
||||||
{ key: 'markAudioCard', actionId: 'markAudioCard' },
|
{ key: 'markAudioCard', actionId: 'markAudioCard' },
|
||||||
{ key: 'openCharacterDictionary', actionId: 'openCharacterDictionary' },
|
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
|
||||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
promoteSettingsWindowAboveOverlay,
|
||||||
|
shouldPromoteSettingsWindowAboveOverlay,
|
||||||
|
} from './settings-window-z-order';
|
||||||
|
|
||||||
|
test('settings window promotion only applies to Hyprland sessions', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldPromoteSettingsWindowAboveOverlay('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldPromoteSettingsWindowAboveOverlay('linux', { WAYLAND_DISPLAY: 'wayland-1' }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldPromoteSettingsWindowAboveOverlay('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promoteSettingsWindowAboveOverlay raises Hyprland settings windows above the overlay', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const promoted = promoteSettingsWindowAboveOverlay(
|
||||||
|
{
|
||||||
|
isDestroyed: () => false,
|
||||||
|
getTitle: () => 'SubMiner Settings',
|
||||||
|
setAlwaysOnTop: (flag: boolean) => calls.push(`always-on-top:${flag}`),
|
||||||
|
moveTop: () => calls.push('move-top'),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
|
||||||
|
ensureHyprlandWindowFloatingByTitle: ({ title }) => {
|
||||||
|
calls.push(`hyprland-top:${title}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(promoted, true);
|
||||||
|
assert.deepEqual(calls, ['always-on-top:true', 'move-top', 'hyprland-top:SubMiner Settings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promoteSettingsWindowAboveOverlay skips destroyed windows', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const promoted = promoteSettingsWindowAboveOverlay(
|
||||||
|
{
|
||||||
|
isDestroyed: () => true,
|
||||||
|
getTitle: () => 'SubMiner Settings',
|
||||||
|
setAlwaysOnTop: () => calls.push('always-on-top'),
|
||||||
|
moveTop: () => calls.push('move-top'),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
platform: 'linux',
|
||||||
|
env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(promoted, false);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import {
|
||||||
|
ensureHyprlandWindowFloatingByTitle,
|
||||||
|
shouldAttemptHyprlandWindowPlacement,
|
||||||
|
} from './hyprland-window-placement';
|
||||||
|
|
||||||
|
type SettingsWindowLevelController = Pick<
|
||||||
|
BrowserWindow,
|
||||||
|
'getTitle' | 'isDestroyed' | 'moveTop' | 'setAlwaysOnTop'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type PromoteSettingsWindowOptions = {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
ensureHyprlandWindowFloatingByTitle?: typeof ensureHyprlandWindowFloatingByTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shouldPromoteSettingsWindowAboveOverlay(
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): boolean {
|
||||||
|
return shouldAttemptHyprlandWindowPlacement(platform, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteSettingsWindowAboveOverlay(
|
||||||
|
window: SettingsWindowLevelController,
|
||||||
|
options: PromoteSettingsWindowOptions = {},
|
||||||
|
): boolean {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
if (window.isDestroyed() || !shouldPromoteSettingsWindowAboveOverlay(platform, env)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setAlwaysOnTop(true);
|
||||||
|
window.moveTop();
|
||||||
|
|
||||||
|
const title = window.getTitle().trim();
|
||||||
|
if (title) {
|
||||||
|
(options.ensureHyprlandWindowFloatingByTitle ?? ensureHyprlandWindowFloatingByTitle)({
|
||||||
|
title,
|
||||||
|
platform,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ export interface TokenizerServiceDeps {
|
|||||||
getNameMatchEnabled?: () => boolean;
|
getNameMatchEnabled?: () => boolean;
|
||||||
getNameMatchImagesEnabled?: () => boolean;
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
|
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -85,6 +86,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
getNameMatchEnabled?: () => boolean;
|
getNameMatchEnabled?: () => boolean;
|
||||||
getNameMatchImagesEnabled?: () => boolean;
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
|
getCurrentCharacterDictionaryMediaId?: () => number | null;
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -237,6 +239,7 @@ export function createTokenizerDepsRuntime(
|
|||||||
getNameMatchEnabled: options.getNameMatchEnabled,
|
getNameMatchEnabled: options.getNameMatchEnabled,
|
||||||
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
||||||
getCharacterNameImage: options.getCharacterNameImage,
|
getCharacterNameImage: options.getCharacterNameImage,
|
||||||
|
getCurrentCharacterDictionaryMediaId: options.getCurrentCharacterDictionaryMediaId,
|
||||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||||
getFrequencyRank: options.getFrequencyRank,
|
getFrequencyRank: options.getFrequencyRank,
|
||||||
@@ -708,6 +711,7 @@ async function parseWithYomitanInternalParser(
|
|||||||
): Promise<MergedToken[] | null> {
|
): Promise<MergedToken[] | null> {
|
||||||
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
const selectedTokens = await requestYomitanScanTokens(text, deps, logger, {
|
||||||
includeNameMatchMetadata: options.nameMatchEnabled,
|
includeNameMatchMetadata: options.nameMatchEnabled,
|
||||||
|
currentCharacterDictionaryMediaId: deps.getCurrentCharacterDictionaryMediaId?.() ?? null,
|
||||||
});
|
});
|
||||||
if (!selectedTokens || selectedTokens.length === 0) {
|
if (!selectedTokens || selectedTokens.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1281,6 +1281,158 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al
|
|||||||
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
|
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 () => {
|
test('requestYomitanScanTokens preserves matched headword word classes', async () => {
|
||||||
let scannerScript = '';
|
let scannerScript = '';
|
||||||
const deps = createDeps(async (script) => {
|
const deps = createDeps(async (script) => {
|
||||||
|
|||||||
@@ -1106,11 +1106,85 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
|||||||
}
|
}
|
||||||
return getDictionaryEntryNames(entry).some((name) => name.startsWith("SubMiner Character Dictionary"));
|
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;
|
let matchedNameDictionary = false;
|
||||||
if (includeNameMatchMetadata) {
|
if (includeNameMatchMetadata) {
|
||||||
for (const dictionaryEntry of dictionaryEntries || []) {
|
for (const dictionaryEntry of currentMediaDictionaryEntries || []) {
|
||||||
if (!isNameDictionaryEntry(dictionaryEntry)) { continue; }
|
if (!isCurrentMediaNameDictionaryEntry(dictionaryEntry)) { continue; }
|
||||||
for (const match of exactPrimaryMatches) {
|
for (const match of exactPrimaryMatches) {
|
||||||
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
|
if (match.dictionaryEntry !== dictionaryEntry) { continue; }
|
||||||
matchedNameDictionary = true;
|
matchedNameDictionary = true;
|
||||||
@@ -1121,13 +1195,14 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
|||||||
}
|
}
|
||||||
const preferredMatch = exactPrimaryMatches[0];
|
const preferredMatch = exactPrimaryMatches[0];
|
||||||
if (preferredMatch) {
|
if (preferredMatch) {
|
||||||
const exactFrequencyMatches = collectExactHeadwordMatches(dictionaryEntries, token, false)
|
const exactFrequencyMatches = collectExactHeadwordMatches(currentMediaDictionaryEntries, token, false)
|
||||||
.filter((match) => sameHeadword(match, preferredMatch));
|
.filter((match) => sameHeadword(match, preferredMatch));
|
||||||
return {
|
return {
|
||||||
term: preferredMatch.headword.term,
|
term: preferredMatch.headword.term,
|
||||||
reading: preferredMatch.headword.reading,
|
reading: preferredMatch.headword.reading,
|
||||||
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
||||||
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
isNameMatch:
|
||||||
|
matchedNameDictionary || isCurrentMediaNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
||||||
frequencyRank: getBestFrequencyRankForMatches(
|
frequencyRank: getBestFrequencyRankForMatches(
|
||||||
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
||||||
dictionaryPriorityByName,
|
dictionaryPriorityByName,
|
||||||
@@ -1144,6 +1219,7 @@ function buildYomitanScanningScript(
|
|||||||
profileIndex: number,
|
profileIndex: number,
|
||||||
scanLength: number,
|
scanLength: number,
|
||||||
includeNameMatchMetadata: boolean,
|
includeNameMatchMetadata: boolean,
|
||||||
|
currentCharacterDictionaryMediaId: number | null,
|
||||||
dictionaryPriorityByName: Record<string, number>,
|
dictionaryPriorityByName: Record<string, number>,
|
||||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>,
|
||||||
): string {
|
): string {
|
||||||
@@ -1169,6 +1245,11 @@ function buildYomitanScanningScript(
|
|||||||
});
|
});
|
||||||
${YOMITAN_SCANNING_HELPERS}
|
${YOMITAN_SCANNING_HELPERS}
|
||||||
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
const includeNameMatchMetadata = ${includeNameMatchMetadata ? 'true' : 'false'};
|
||||||
|
const currentCharacterDictionaryMediaId = ${
|
||||||
|
currentCharacterDictionaryMediaId !== null
|
||||||
|
? String(currentCharacterDictionaryMediaId)
|
||||||
|
: 'null'
|
||||||
|
};
|
||||||
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
const dictionaryPriorityByName = ${JSON.stringify(dictionaryPriorityByName)};
|
||||||
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
const dictionaryFrequencyModeByName = ${JSON.stringify(dictionaryFrequencyModeByName)};
|
||||||
const text = ${JSON.stringify(text)};
|
const text = ${JSON.stringify(text)};
|
||||||
@@ -1320,6 +1401,7 @@ export async function requestYomitanScanTokens(
|
|||||||
logger: LoggerLike,
|
logger: LoggerLike,
|
||||||
options?: {
|
options?: {
|
||||||
includeNameMatchMetadata?: boolean;
|
includeNameMatchMetadata?: boolean;
|
||||||
|
currentCharacterDictionaryMediaId?: number | null;
|
||||||
},
|
},
|
||||||
): Promise<YomitanScanToken[] | null> {
|
): Promise<YomitanScanToken[] | null> {
|
||||||
const yomitanExt = deps.getYomitanExt();
|
const yomitanExt = deps.getYomitanExt();
|
||||||
@@ -1355,6 +1437,11 @@ export async function requestYomitanScanTokens(
|
|||||||
profileIndex,
|
profileIndex,
|
||||||
scanLength,
|
scanLength,
|
||||||
options?.includeNameMatchMetadata === true,
|
options?.includeNameMatchMetadata === true,
|
||||||
|
typeof options?.currentCharacterDictionaryMediaId === 'number' &&
|
||||||
|
Number.isFinite(options.currentCharacterDictionaryMediaId) &&
|
||||||
|
options.currentCharacterDictionaryMediaId > 0
|
||||||
|
? Math.floor(options.currentCharacterDictionaryMediaId)
|
||||||
|
: null,
|
||||||
metadata?.dictionaryPriorityByName ?? {},
|
metadata?.dictionaryPriorityByName ?? {},
|
||||||
metadata?.dictionaryFrequencyModeByName ?? {},
|
metadata?.dictionaryFrequencyModeByName ?? {},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -106,20 +106,32 @@ test('yomitan settings URL disables the embedded popup preview', () => {
|
|||||||
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
showYomitanSettingsWindow({
|
showYomitanSettingsWindow(
|
||||||
isDestroyed: () => false,
|
{
|
||||||
isMinimized: () => true,
|
isDestroyed: () => false,
|
||||||
restore: () => calls.push('restore'),
|
isMinimized: () => true,
|
||||||
getSize: () => [1200, 800],
|
restore: () => calls.push('restore'),
|
||||||
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
|
getSize: () => [1200, 800],
|
||||||
webContents: {
|
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
|
||||||
invalidate: () => calls.push('invalidate'),
|
webContents: {
|
||||||
|
invalidate: () => calls.push('invalidate'),
|
||||||
|
},
|
||||||
|
show: () => calls.push('show'),
|
||||||
|
focus: () => calls.push('focus'),
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||||
},
|
},
|
||||||
show: () => calls.push('show'),
|
);
|
||||||
focus: () => calls.push('focus'),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
|
assert.deepEqual(calls, [
|
||||||
|
'restore',
|
||||||
|
'set-size:1200x800',
|
||||||
|
'invalidate',
|
||||||
|
'show',
|
||||||
|
'focus',
|
||||||
|
'promote',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
|
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
|
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
import { promoteSettingsWindowAboveOverlay } from './settings-window-z-order';
|
||||||
|
|
||||||
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
|
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
|
||||||
const logger = createLogger('main:yomitan-settings');
|
const logger = createLogger('main:yomitan-settings');
|
||||||
@@ -136,7 +137,12 @@ export function buildYomitanSettingsUrl(extensionId: string): string {
|
|||||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
|
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
export function showYomitanSettingsWindow(
|
||||||
|
settingsWindow: BrowserWindow,
|
||||||
|
options: {
|
||||||
|
promoteSettingsWindowAboveOverlay?: (settingsWindow: BrowserWindow) => void;
|
||||||
|
} = {},
|
||||||
|
): void {
|
||||||
if (settingsWindow.isDestroyed()) {
|
if (settingsWindow.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,6 +154,7 @@ export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
|||||||
settingsWindow.webContents.invalidate();
|
settingsWindow.webContents.invalidate();
|
||||||
settingsWindow.show();
|
settingsWindow.show();
|
||||||
settingsWindow.focus();
|
settingsWindow.focus();
|
||||||
|
(options.promoteSettingsWindowAboveOverlay ?? promoteSettingsWindowAboveOverlay)(settingsWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
|
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
|
||||||
@@ -177,6 +184,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
|||||||
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
|
logger.info('Creating new settings window for extension:', options.yomitanExt.id);
|
||||||
|
|
||||||
const settingsWindow = new ElectronBrowserWindow({
|
const settingsWindow = new ElectronBrowserWindow({
|
||||||
|
title: 'Yomitan Settings',
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
show: false,
|
show: false,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
|
|||||||
shortcuts: {
|
shortcuts: {
|
||||||
mineSentence: 'KeyQ',
|
mineSentence: 'KeyQ',
|
||||||
openRuntimeOptions: 'Digit9',
|
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.mineSentence, 'Q');
|
||||||
assert.equal(resolved.openRuntimeOptions, '9');
|
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', () => {
|
test('preserves null shortcut overrides so defaults can be disabled', () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface ConfiguredShortcuts {
|
|||||||
multiCopyTimeoutMs: number;
|
multiCopyTimeoutMs: number;
|
||||||
toggleSecondarySub: string | null | undefined;
|
toggleSecondarySub: string | null | undefined;
|
||||||
markAudioCard: string | null | undefined;
|
markAudioCard: string | null | undefined;
|
||||||
openCharacterDictionary: string | null | undefined;
|
openCharacterDictionaryManager: string | null | undefined;
|
||||||
openRuntimeOptions: string | null | undefined;
|
openRuntimeOptions: string | null | undefined;
|
||||||
openJimaku: string | null | undefined;
|
openJimaku: string | null | undefined;
|
||||||
openSessionHelp: string | null | undefined;
|
openSessionHelp: string | null | undefined;
|
||||||
@@ -58,7 +58,9 @@ export function resolveConfiguredShortcuts(
|
|||||||
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
|
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
|
||||||
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
|
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
|
||||||
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
|
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
|
||||||
openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')),
|
openCharacterDictionaryManager: normalizeShortcut(
|
||||||
|
shortcutValue('openCharacterDictionaryManager'),
|
||||||
|
),
|
||||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||||
|
|||||||
+118
-11
@@ -332,6 +332,7 @@ import {
|
|||||||
mineSentenceCard as mineSentenceCardCore,
|
mineSentenceCard as mineSentenceCardCore,
|
||||||
openYomitanSettingsWindow,
|
openYomitanSettingsWindow,
|
||||||
playNextSubtitleRuntime,
|
playNextSubtitleRuntime,
|
||||||
|
promoteSettingsWindowAboveOverlay,
|
||||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||||
replayCurrentSubtitleRuntime,
|
replayCurrentSubtitleRuntime,
|
||||||
resolveJellyfinPlaybackPlanRuntime,
|
resolveJellyfinPlaybackPlanRuntime,
|
||||||
@@ -499,7 +500,10 @@ import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './mai
|
|||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||||
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-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 { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||||
@@ -519,7 +523,13 @@ import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-
|
|||||||
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
import { createDiscordPresenceRuntime } from './main/runtime/discord-presence-runtime';
|
||||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||||
import { createCharacterDictionaryImageLookup } from './main/character-dictionary-runtime/image-lookup';
|
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 { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
|
||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
@@ -565,6 +575,7 @@ import {
|
|||||||
createCreateJellyfinSetupWindowHandler,
|
createCreateJellyfinSetupWindowHandler,
|
||||||
} from './main/runtime/setup-window-factory';
|
} from './main/runtime/setup-window-factory';
|
||||||
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
|
||||||
|
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './main/runtime/settings-window-z-order';
|
||||||
import {
|
import {
|
||||||
isSameYoutubeMediaPath,
|
isSameYoutubeMediaPath,
|
||||||
isYoutubeMediaPath,
|
isYoutubeMediaPath,
|
||||||
@@ -836,7 +847,15 @@ const bootServices = createMainBootServices({
|
|||||||
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
|
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
|
||||||
createLogger,
|
createLogger,
|
||||||
createMainRuntimeRegistry,
|
createMainRuntimeRegistry,
|
||||||
createOverlayManager,
|
createOverlayManager: () =>
|
||||||
|
createOverlayManager({
|
||||||
|
shouldPromoteWindowOnBoundsUpdate: (window) =>
|
||||||
|
!shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window,
|
||||||
|
mainWindow: overlayManager.getMainWindow(),
|
||||||
|
separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
createOverlayModalInputState,
|
createOverlayModalInputState,
|
||||||
createOverlayContentMeasurementStore: ({ logger }) => {
|
createOverlayContentMeasurementStore: ({ logger }) => {
|
||||||
const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
const buildHandler = createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
||||||
@@ -1897,6 +1916,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
|||||||
openCharacterDictionary: () => {
|
openCharacterDictionary: () => {
|
||||||
openCharacterDictionaryOverlay();
|
openCharacterDictionaryOverlay();
|
||||||
},
|
},
|
||||||
|
openCharacterDictionaryManager: () => {
|
||||||
|
openCharacterDictionaryManagerOverlay();
|
||||||
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
openJimakuOverlay();
|
openJimakuOverlay();
|
||||||
},
|
},
|
||||||
@@ -2034,6 +2056,8 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
preloadPath: path.join(__dirname, 'preload-settings.js'),
|
||||||
}),
|
}),
|
||||||
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
settingsHtmlPath: path.join(__dirname, 'settings', 'index.html'),
|
||||||
|
promoteSettingsWindowAboveOverlay: (window) =>
|
||||||
|
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
|
||||||
openPath: (targetPath) => shell.openPath(targetPath),
|
openPath: (targetPath) => shell.openPath(targetPath),
|
||||||
ipcMain,
|
ipcMain,
|
||||||
ipcChannels: IPC_CHANNELS.request,
|
ipcChannels: IPC_CHANNELS.request,
|
||||||
@@ -2187,10 +2211,6 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
|||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
|
||||||
userDataPath: USER_DATA_PATH,
|
|
||||||
});
|
|
||||||
|
|
||||||
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
@@ -2304,6 +2324,11 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||||
|
userDataPath: USER_DATA_PATH,
|
||||||
|
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
|
});
|
||||||
|
|
||||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
@@ -2914,6 +2939,14 @@ function openCharacterDictionaryOverlay(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCharacterDictionaryManagerOverlay(): void {
|
||||||
|
openOverlayHostedModalWithOsd(
|
||||||
|
openCharacterDictionaryManagerModalRuntime,
|
||||||
|
'Character dictionary manager unavailable.',
|
||||||
|
'Failed to open character dictionary manager.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openControllerSelectOverlay(): void {
|
function openControllerSelectOverlay(): void {
|
||||||
openOverlayHostedModalWithOsd(
|
openOverlayHostedModalWithOsd(
|
||||||
openControllerSelectModalRuntime,
|
openControllerSelectModalRuntime,
|
||||||
@@ -4736,6 +4769,8 @@ const {
|
|||||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||||
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
||||||
|
getCurrentCharacterDictionaryMediaId: () =>
|
||||||
|
characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
getFrequencyDictionaryEnabled: () =>
|
getFrequencyDictionaryEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -4927,8 +4962,17 @@ const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
|||||||
|
|
||||||
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||||
shouldSuppressOverlayWindowLevel: (window) =>
|
shouldSuppressOverlayWindowLevel: (window) => {
|
||||||
appState.statsOverlayVisible && window === overlayManager.getMainWindow(),
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
return (
|
||||||
|
(appState.statsOverlayVisible && window === mainWindow) ||
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: [appState.configSettingsWindow, appState.yomitanSettingsWindow],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
|
ensureOverlayWindowLevelCore: (window) => ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||||
afterEnsureOverlayWindowLevel: () => {
|
afterEnsureOverlayWindowLevel: () => {
|
||||||
promoteStatsOverlayAbovePlayback();
|
promoteStatsOverlayAbovePlayback();
|
||||||
@@ -5714,6 +5758,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
|||||||
openJimaku: () => openJimakuOverlay(),
|
openJimaku: () => openJimakuOverlay(),
|
||||||
openSessionHelp: () => openSessionHelpOverlay(),
|
openSessionHelp: () => openSessionHelpOverlay(),
|
||||||
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
|
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
|
||||||
|
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||||
openControllerSelect: () => openControllerSelectOverlay(),
|
openControllerSelect: () => openControllerSelectOverlay(),
|
||||||
openControllerDebug: () => openControllerDebugOverlay(),
|
openControllerDebug: () => openControllerDebugOverlay(),
|
||||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||||
@@ -5977,8 +6022,31 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
||||||
getCharacterDictionarySelection: (searchTitle?: string) =>
|
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||||
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
|
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
|
||||||
setCharacterDictionarySelection: async (mediaId: number) =>
|
setCharacterDictionarySelection: async (
|
||||||
applyCharacterDictionarySelection(
|
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 },
|
{ mediaId },
|
||||||
{
|
{
|
||||||
setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request),
|
setManualSelection: (request) => characterDictionaryRuntime.setManualSelection(request),
|
||||||
@@ -5986,7 +6054,46 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
|
runSyncNow: () => characterDictionaryAutoSyncRuntime.runSyncNow(),
|
||||||
warn: (message, error) => logger.warn(message, error),
|
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(),
|
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
||||||
...playlistBrowserMainDeps,
|
...playlistBrowserMainDeps,
|
||||||
getImmersionTracker: () => appState.immersionTracker,
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
|
|||||||
@@ -119,3 +119,48 @@ test('buildSnapshotFromCharacters shows Japanese aliases without adding romanize
|
|||||||
assert.equal(terms.includes('アクア'), true);
|
assert.equal(terms.includes('アクア'), true);
|
||||||
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<string, string>;
|
||||||
|
content: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(glossaryEntry.content.data?.subminerMediaId, '21699');
|
||||||
|
assert.equal(
|
||||||
|
glossaryEntry.content.content.some((node) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(node, 'subminerMediaId'),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
|
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 18;
|
||||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||||
|
|
||||||
export const HONORIFIC_SUFFIXES = [
|
export const HONORIFIC_SUFFIXES = [
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | nu
|
|||||||
|
|
||||||
export function createDefinitionGlossary(
|
export function createDefinitionGlossary(
|
||||||
character: CharacterRecord,
|
character: CharacterRecord,
|
||||||
|
mediaId: number,
|
||||||
mediaTitle: string,
|
mediaTitle: string,
|
||||||
imagePath: string | null,
|
imagePath: string | null,
|
||||||
vaImagePaths: Map<number, string>,
|
vaImagePaths: Map<number, string>,
|
||||||
@@ -258,7 +259,7 @@ export function createDefinitionGlossary(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'structured-content',
|
type: 'structured-content',
|
||||||
content: { tag: 'div', content },
|
content: { tag: 'div', data: { subminerMediaId: String(mediaId) }, content },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import * as path from 'path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
|
import {
|
||||||
|
buildCharacterNameImageIndexFromSnapshots,
|
||||||
|
createCharacterDictionaryImageLookup,
|
||||||
|
} from './image-lookup';
|
||||||
import type { CharacterDictionarySnapshot } from './types';
|
import type { CharacterDictionarySnapshot } from './types';
|
||||||
|
|
||||||
const PNG_1X1_BASE64 =
|
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}`);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ function normalizeLookupTerm(term: string): string {
|
|||||||
return term.trim();
|
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 {
|
function getSnapshotsDir(outputDir: string): string {
|
||||||
return path.join(outputDir, 'snapshots');
|
return path.join(outputDir, 'snapshots');
|
||||||
}
|
}
|
||||||
@@ -209,8 +217,9 @@ export function buildCharacterNameImageIndexFromSnapshots(
|
|||||||
export function createCharacterDictionaryImageLookup(deps: {
|
export function createCharacterDictionaryImageLookup(deps: {
|
||||||
userDataPath?: string;
|
userDataPath?: string;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
|
getCurrentMediaId?: () => number | null | undefined;
|
||||||
}): {
|
}): {
|
||||||
get: (term: string) => CharacterNameImage | null;
|
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
} {
|
} {
|
||||||
const outputDir =
|
const outputDir =
|
||||||
@@ -218,10 +227,12 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||||
let signature: string | null = null;
|
let signature: string | null = null;
|
||||||
let index = new Map<string, CharacterNameImage>();
|
let index = new Map<string, CharacterNameImage>();
|
||||||
|
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
|
|
||||||
function refreshIfNeeded(): void {
|
function refreshIfNeeded(): void {
|
||||||
if (!outputDir) {
|
if (!outputDir) {
|
||||||
index = new Map<string, CharacterNameImage>();
|
index = new Map<string, CharacterNameImage>();
|
||||||
|
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
signature = '';
|
signature = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -230,16 +241,29 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
index = new Map<string, CharacterNameImage>();
|
||||||
|
indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
|
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||||
|
appendSnapshotImages(index, snapshot);
|
||||||
|
const mediaIndex = new Map<string, CharacterNameImage>();
|
||||||
|
appendSnapshotImages(mediaIndex, snapshot);
|
||||||
|
if (mediaIndex.size > 0) {
|
||||||
|
indexByMediaId.set(snapshot.mediaId, mediaIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get(term: string): CharacterNameImage | null {
|
get(term: string, mediaId?: number | null): CharacterNameImage | null {
|
||||||
const normalizedTerm = normalizeLookupTerm(term);
|
const normalizedTerm = normalizeLookupTerm(term);
|
||||||
if (!normalizedTerm) {
|
if (!normalizedTerm) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
refreshIfNeeded();
|
refreshIfNeeded();
|
||||||
|
const scopedMediaId = normalizeLookupMediaId(mediaId ?? deps.getCurrentMediaId?.() ?? null);
|
||||||
|
if (scopedMediaId !== null) {
|
||||||
|
return indexByMediaId.get(scopedMediaId)?.get(normalizedTerm) ?? null;
|
||||||
|
}
|
||||||
return index.get(normalizedTerm) ?? null;
|
return index.get(normalizedTerm) ?? null;
|
||||||
},
|
},
|
||||||
invalidate(): void {
|
invalidate(): void {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function buildSnapshotFromCharacters(
|
|||||||
const candidateTerms = buildNameTerms(character);
|
const candidateTerms = buildNameTerms(character);
|
||||||
const glossary = createDefinitionGlossary(
|
const glossary = createDefinitionGlossary(
|
||||||
character,
|
character,
|
||||||
|
mediaId,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
imagePath,
|
imagePath,
|
||||||
vaImagePaths,
|
vaImagePaths,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildNameTerms } from './term-building';
|
||||||
|
import type { CharacterRecord } from './types';
|
||||||
|
|
||||||
|
function characterRecord(overrides: Partial<CharacterRecord>): CharacterRecord {
|
||||||
|
return {
|
||||||
|
id: 136073,
|
||||||
|
role: 'primary',
|
||||||
|
firstNameHint: 'Chi-Yul',
|
||||||
|
fullName: 'Chi-Yul Song',
|
||||||
|
lastNameHint: 'Song',
|
||||||
|
nativeName: '송치율',
|
||||||
|
alternativeNames: [],
|
||||||
|
bloodType: '',
|
||||||
|
birthday: null,
|
||||||
|
description: '',
|
||||||
|
imageUrl: null,
|
||||||
|
age: '',
|
||||||
|
sex: '',
|
||||||
|
voiceActors: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildNameTerms adds surname honorifics from Japanese localized aliases', () => {
|
||||||
|
const terms = buildNameTerms(
|
||||||
|
characterRecord({
|
||||||
|
alternativeNames: ['Isao Mabuchi (馬渕勲)', 'Chi-Yeol (송치열)'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(terms.includes('馬渕勲'));
|
||||||
|
assert.ok(terms.includes('馬渕勲さん'));
|
||||||
|
assert.ok(terms.includes('馬渕'));
|
||||||
|
assert.ok(terms.includes('馬渕さん'));
|
||||||
|
assert.ok(!terms.includes('송치'));
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
addRomanizedKanaAliases,
|
addRomanizedKanaAliases,
|
||||||
buildReading,
|
buildReading,
|
||||||
buildReadingFromRomanized,
|
buildReadingFromRomanized,
|
||||||
|
containsKanji,
|
||||||
hasKanaOnly,
|
hasKanaOnly,
|
||||||
isRomanizedName,
|
isRomanizedName,
|
||||||
splitJapaneseName,
|
splitJapaneseName,
|
||||||
@@ -39,6 +40,25 @@ function expandRawNameVariants(rawName: string): string[] {
|
|||||||
return [...variants];
|
return [...variants];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isJapaneseNameSplitCandidate(name: string): boolean {
|
||||||
|
const compact = name.replace(/[\s\u3000・・·•]/g, '');
|
||||||
|
return (
|
||||||
|
containsKanji(compact) && /^[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff々〆ヵヶー]+$/.test(compact)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addJapaneseNameParts(character: CharacterRecord, name: string, terms: Set<string>): void {
|
||||||
|
if (!isJapaneseNameSplitCandidate(name)) return;
|
||||||
|
|
||||||
|
const nameParts = splitJapaneseName(name, character.firstNameHint, character.lastNameHint);
|
||||||
|
if (nameParts.family) {
|
||||||
|
terms.add(nameParts.family);
|
||||||
|
}
|
||||||
|
if (nameParts.given) {
|
||||||
|
terms.add(nameParts.given);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildNameTerms(character: CharacterRecord): string[] {
|
export function buildNameTerms(character: CharacterRecord): string[] {
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
const romanizedBase = new Set<string>();
|
const romanizedBase = new Set<string>();
|
||||||
@@ -73,6 +93,10 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
|||||||
target.add(part);
|
target.add(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (target === base) {
|
||||||
|
addJapaneseNameParts(character, name, base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
|
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
|
||||||
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
||||||
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
||||||
|
getCharacterDictionaryManagerSnapshot?: IpcDepsRuntimeOptions['getCharacterDictionaryManagerSnapshot'];
|
||||||
|
removeCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['removeCharacterDictionaryManagedEntry'];
|
||||||
|
moveCharacterDictionaryManagedEntry?: IpcDepsRuntimeOptions['moveCharacterDictionaryManagedEntry'];
|
||||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||||
@@ -272,6 +275,9 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
|
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
|
||||||
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
||||||
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
||||||
|
getCharacterDictionaryManagerSnapshot: params.getCharacterDictionaryManagerSnapshot,
|
||||||
|
removeCharacterDictionaryManagedEntry: params.removeCharacterDictionaryManagedEntry,
|
||||||
|
moveCharacterDictionaryManagedEntry: params.moveCharacterDictionaryManagedEntry,
|
||||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
|||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
openCharacterDictionary: () => void;
|
openCharacterDictionary: () => void;
|
||||||
|
openCharacterDictionaryManager: () => void;
|
||||||
openJimaku: () => void;
|
openJimaku: () => void;
|
||||||
markAudioCard: () => Promise<void>;
|
markAudioCard: () => Promise<void>;
|
||||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||||
@@ -53,6 +54,9 @@ export function createOverlayShortcutsRuntimeService(
|
|||||||
openCharacterDictionary: () => {
|
openCharacterDictionary: () => {
|
||||||
input.openCharacterDictionary();
|
input.openCharacterDictionary();
|
||||||
},
|
},
|
||||||
|
openCharacterDictionaryManager: () => {
|
||||||
|
input.openCharacterDictionaryManager();
|
||||||
|
},
|
||||||
openJimaku: () => {
|
openJimaku: () => {
|
||||||
input.openJimaku();
|
input.openJimaku();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,8 +152,5 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']);
|
||||||
'sequencer:importing:importing',
|
|
||||||
'desktop:SubMiner:importing',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import test from 'node:test';
|
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 {
|
function makeTempDir(): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||||
@@ -17,6 +22,94 @@ function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void
|
|||||||
return { promise, resolve };
|
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 () => {
|
test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const imported: string[] = [];
|
const imported: string[] = [];
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ type AutoSyncDictionaryInfo = {
|
|||||||
revision?: string | number;
|
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 {
|
export interface CharacterDictionaryAutoSyncConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
maxLoaded: number;
|
maxLoaded: number;
|
||||||
@@ -154,6 +169,167 @@ function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
|
|||||||
fs.writeFileSync(statePath, JSON.stringify(persistedState, null, 2), 'utf8');
|
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 {
|
function arraysEqual(left: number[], right: number[]): boolean {
|
||||||
if (left.length !== right.length) return false;
|
if (left.length !== right.length) return false;
|
||||||
for (let i = 0; i < left.length; i += 1) {
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
@@ -205,9 +381,10 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
): {
|
): {
|
||||||
scheduleSync: () => void;
|
scheduleSync: () => void;
|
||||||
runSyncNow: () => Promise<void>;
|
runSyncNow: () => Promise<void>;
|
||||||
|
getCurrentMediaId: () => number | null;
|
||||||
} {
|
} {
|
||||||
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
|
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 schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
|
||||||
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
||||||
const debounceMs = 800;
|
const debounceMs = 800;
|
||||||
@@ -216,6 +393,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let syncInFlight = false;
|
let syncInFlight = false;
|
||||||
let runQueued = false;
|
let runQueued = false;
|
||||||
|
let activeCurrentMediaId: number | null = null;
|
||||||
|
|
||||||
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -238,6 +416,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
const runSyncOnce = async (): Promise<void> => {
|
const runSyncOnce = async (): Promise<void> => {
|
||||||
const config = deps.getConfig();
|
const config = deps.getConfig();
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
|
activeCurrentMediaId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +429,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
onChecking: ({ mediaId, mediaTitle }) => {
|
onChecking: ({ mediaId, mediaTitle }) => {
|
||||||
currentMediaId = mediaId;
|
currentMediaId = mediaId;
|
||||||
currentMediaTitle = mediaTitle;
|
currentMediaTitle = mediaTitle;
|
||||||
|
activeCurrentMediaId = mediaId;
|
||||||
deps.onSyncStatus?.({
|
deps.onSyncStatus?.({
|
||||||
phase: 'checking',
|
phase: 'checking',
|
||||||
mediaId,
|
mediaId,
|
||||||
@@ -260,6 +440,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
onGenerating: ({ mediaId, mediaTitle }) => {
|
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||||
currentMediaId = mediaId;
|
currentMediaId = mediaId;
|
||||||
currentMediaTitle = mediaTitle;
|
currentMediaTitle = mediaTitle;
|
||||||
|
activeCurrentMediaId = mediaId;
|
||||||
deps.onSyncStatus?.({
|
deps.onSyncStatus?.({
|
||||||
phase: 'generating',
|
phase: 'generating',
|
||||||
mediaId,
|
mediaId,
|
||||||
@@ -270,6 +451,7 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
});
|
});
|
||||||
currentMediaId = snapshot.mediaId;
|
currentMediaId = snapshot.mediaId;
|
||||||
currentMediaTitle = snapshot.mediaTitle;
|
currentMediaTitle = snapshot.mediaTitle;
|
||||||
|
activeCurrentMediaId = snapshot.mediaId;
|
||||||
const state = readAutoSyncState(statePath);
|
const state = readAutoSyncState(statePath);
|
||||||
const staleMediaIds = new Set(
|
const staleMediaIds = new Set(
|
||||||
(snapshot.staleMediaIds ?? [])
|
(snapshot.staleMediaIds ?? [])
|
||||||
@@ -453,5 +635,6 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
runSyncNow: async () => {
|
runSyncNow: async () => {
|
||||||
await runSyncOnce();
|
await runSyncOnce();
|
||||||
},
|
},
|
||||||
|
getCurrentMediaId: () => activeCurrentMediaId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-
|
|||||||
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
|
const CHARACTER_DICTIONARY_MODAL: OverlayHostedModal = 'character-dictionary';
|
||||||
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
|
const CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
export async function openCharacterDictionaryModal(deps: {
|
async function openCharacterDictionaryModalChannel(deps: {
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
ensureOverlayStartupPrereqs: () => void;
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||||
sendToActiveOverlayWindow: (
|
sendToActiveOverlayWindow: (
|
||||||
@@ -18,6 +18,8 @@ export async function openCharacterDictionaryModal(deps: {
|
|||||||
) => boolean;
|
) => boolean;
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
|
channel: string;
|
||||||
|
retryWarning: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
return await retryOverlayModalOpen(
|
return await retryOverlayModalOpen(
|
||||||
{
|
{
|
||||||
@@ -27,8 +29,7 @@ export async function openCharacterDictionaryModal(deps: {
|
|||||||
{
|
{
|
||||||
modal: CHARACTER_DICTIONARY_MODAL,
|
modal: CHARACTER_DICTIONARY_MODAL,
|
||||||
timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS,
|
timeoutMs: CHARACTER_DICTIONARY_OPEN_TIMEOUT_MS,
|
||||||
retryWarning:
|
retryWarning: deps.retryWarning,
|
||||||
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
|
||||||
sendOpen: () =>
|
sendOpen: () =>
|
||||||
openOverlayHostedModal(
|
openOverlayHostedModal(
|
||||||
{
|
{
|
||||||
@@ -38,7 +39,7 @@ export async function openCharacterDictionaryModal(deps: {
|
|||||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
channel: IPC_CHANNELS.event.characterDictionaryOpen,
|
channel: deps.channel,
|
||||||
modal: CHARACTER_DICTIONARY_MODAL,
|
modal: CHARACTER_DICTIONARY_MODAL,
|
||||||
preferModalWindow: true,
|
preferModalWindow: true,
|
||||||
},
|
},
|
||||||
@@ -46,3 +47,30 @@ export async function openCharacterDictionaryModal(deps: {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCharacterDictionaryModalDeps = Omit<
|
||||||
|
Parameters<typeof openCharacterDictionaryModalChannel>[0],
|
||||||
|
'channel' | 'retryWarning'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export async function openCharacterDictionaryModal(
|
||||||
|
deps: OpenCharacterDictionaryModalDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
|||||||
setSettingsWindow(window: TWindow | null): void;
|
setSettingsWindow(window: TWindow | null): void;
|
||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
|
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
||||||
openPath(path: string): Promise<string>;
|
openPath(path: string): Promise<string>;
|
||||||
defaultAnkiConnectUrl: string;
|
defaultAnkiConnectUrl: string;
|
||||||
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
createAnkiClient(url: string): ConfigSettingsAnkiClient;
|
||||||
@@ -144,6 +145,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
setSettingsWindow: deps.setSettingsWindow,
|
setSettingsWindow: deps.setSettingsWindow,
|
||||||
createSettingsWindow: deps.createSettingsWindow,
|
createSettingsWindow: deps.createSettingsWindow,
|
||||||
settingsHtmlPath: deps.settingsHtmlPath,
|
settingsHtmlPath: deps.settingsHtmlPath,
|
||||||
|
promoteSettingsWindowAboveOverlay: deps.promoteSettingsWindowAboveOverlay,
|
||||||
log: deps.log,
|
log: deps.log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const existing = {
|
const existing = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
show: () => calls.push('show'),
|
||||||
focus: () => calls.push('focus'),
|
focus: () => calls.push('focus'),
|
||||||
loadFile: () => calls.push('load'),
|
loadFile: () => calls.push('load'),
|
||||||
on: () => {},
|
on: () => {},
|
||||||
@@ -18,10 +19,11 @@ test('createOpenConfigSettingsWindowHandler focuses existing settings window', (
|
|||||||
throw new Error('Should not create a second window.');
|
throw new Error('Should not create a second window.');
|
||||||
},
|
},
|
||||||
settingsHtmlPath: '/tmp/settings.html',
|
settingsHtmlPath: '/tmp/settings.html',
|
||||||
|
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(open(), true);
|
assert.equal(open(), true);
|
||||||
assert.deepEqual(calls, ['focus']);
|
assert.deepEqual(calls, ['show', 'focus', 'promote']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
|
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
|
||||||
@@ -29,6 +31,7 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
|
|||||||
const handlers: { closed?: () => void } = {};
|
const handlers: { closed?: () => void } = {};
|
||||||
const created = {
|
const created = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
show: () => calls.push('show'),
|
||||||
focus: () => calls.push('focus'),
|
focus: () => calls.push('focus'),
|
||||||
loadFile: (path: string) => calls.push(`load:${path}`),
|
loadFile: (path: string) => calls.push(`load:${path}`),
|
||||||
on: (event: string, handler: () => void) => {
|
on: (event: string, handler: () => void) => {
|
||||||
@@ -41,10 +44,11 @@ test('createOpenConfigSettingsWindowHandler creates window and clears closed sta
|
|||||||
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
|
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
|
||||||
createSettingsWindow: () => created,
|
createSettingsWindow: () => created,
|
||||||
settingsHtmlPath: '/tmp/settings.html',
|
settingsHtmlPath: '/tmp/settings.html',
|
||||||
|
promoteSettingsWindowAboveOverlay: () => calls.push('promote'),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(open(), true);
|
assert.equal(open(), true);
|
||||||
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']);
|
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'show', 'focus', 'promote']);
|
||||||
assert.ok(handlers.closed);
|
assert.ok(handlers.closed);
|
||||||
handlers.closed();
|
handlers.closed();
|
||||||
assert.equal(calls.at(-1), 'set:null');
|
assert.equal(calls.at(-1), 'set:null');
|
||||||
@@ -54,6 +58,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const created = {
|
const created = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
|
show: () => calls.push('show'),
|
||||||
focus: () => calls.push('focus'),
|
focus: () => calls.push('focus'),
|
||||||
loadFile: (path: string) => {
|
loadFile: (path: string) => {
|
||||||
calls.push(`load:${path}`);
|
calls.push(`load:${path}`);
|
||||||
@@ -76,6 +81,7 @@ test('createOpenConfigSettingsWindowHandler clears failed load window state', as
|
|||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'load:/tmp/missing-settings.html',
|
'load:/tmp/missing-settings.html',
|
||||||
'set:window',
|
'set:window',
|
||||||
|
'show',
|
||||||
'focus',
|
'focus',
|
||||||
'set:null',
|
'set:null',
|
||||||
'destroy',
|
'destroy',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface ConfigSettingsWindowLike {
|
export interface ConfigSettingsWindowLike {
|
||||||
isDestroyed(): boolean;
|
isDestroyed(): boolean;
|
||||||
|
show(): void;
|
||||||
focus(): void;
|
focus(): void;
|
||||||
loadFile(path: string): unknown;
|
loadFile(path: string): unknown;
|
||||||
on(event: 'closed', handler: () => void): unknown;
|
on(event: 'closed', handler: () => void): unknown;
|
||||||
@@ -11,6 +12,7 @@ export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWind
|
|||||||
setSettingsWindow(window: TWindow | null): void;
|
setSettingsWindow(window: TWindow | null): void;
|
||||||
createSettingsWindow(): TWindow;
|
createSettingsWindow(): TWindow;
|
||||||
settingsHtmlPath: string;
|
settingsHtmlPath: string;
|
||||||
|
promoteSettingsWindowAboveOverlay?: (window: TWindow) => void;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,9 +20,15 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
|||||||
deps: OpenConfigSettingsWindowDeps<TWindow>,
|
deps: OpenConfigSettingsWindowDeps<TWindow>,
|
||||||
): () => boolean {
|
): () => boolean {
|
||||||
return () => {
|
return () => {
|
||||||
|
const showAndFocus = (window: TWindow): void => {
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
deps.promoteSettingsWindowAboveOverlay?.(window);
|
||||||
|
};
|
||||||
|
|
||||||
const existing = deps.getSettingsWindow();
|
const existing = deps.getSettingsWindow();
|
||||||
if (existing && !existing.isDestroyed()) {
|
if (existing && !existing.isDestroyed()) {
|
||||||
existing.focus();
|
showAndFocus(existing);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +43,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
|||||||
window.on('closed', () => {
|
window.on('closed', () => {
|
||||||
deps.setSettingsWindow(null);
|
deps.setSettingsWindow(null);
|
||||||
});
|
});
|
||||||
window.focus();
|
showAndFocus(window);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
multiCopyTimeoutMs: 5000,
|
multiCopyTimeoutMs: 5000,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
|||||||
multiCopyTimeoutMs: 5000,
|
multiCopyTimeoutMs: 5000,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
|||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||||
openCharacterDictionary: () => calls.push('character-dictionary'),
|
openCharacterDictionary: () => calls.push('character-dictionary'),
|
||||||
|
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
|
||||||
openJimaku: () => calls.push('jimaku'),
|
openJimaku: () => calls.push('jimaku'),
|
||||||
markAudioCard: async () => {
|
markAudioCard: async () => {
|
||||||
calls.push('mark-audio');
|
calls.push('mark-audio');
|
||||||
@@ -49,6 +50,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
|||||||
deps.showMpvOsd('x');
|
deps.showMpvOsd('x');
|
||||||
deps.openRuntimeOptionsPalette();
|
deps.openRuntimeOptionsPalette();
|
||||||
deps.openCharacterDictionary();
|
deps.openCharacterDictionary();
|
||||||
|
deps.openCharacterDictionaryManager();
|
||||||
deps.openJimaku();
|
deps.openJimaku();
|
||||||
await deps.markAudioCard();
|
await deps.markAudioCard();
|
||||||
deps.copySubtitleMultiple(5000);
|
deps.copySubtitleMultiple(5000);
|
||||||
@@ -66,6 +68,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
|||||||
'osd:x',
|
'osd:x',
|
||||||
'runtime-options',
|
'runtime-options',
|
||||||
'character-dictionary',
|
'character-dictionary',
|
||||||
|
'character-dictionary-manager',
|
||||||
'jimaku',
|
'jimaku',
|
||||||
'mark-audio',
|
'mark-audio',
|
||||||
'copy-multi:5000',
|
'copy-multi:5000',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
|||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||||
openCharacterDictionary: () => deps.openCharacterDictionary(),
|
openCharacterDictionary: () => deps.openCharacterDictionary(),
|
||||||
|
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
|
||||||
openJimaku: () => deps.openJimaku(),
|
openJimaku: () => deps.openJimaku(),
|
||||||
markAudioCard: () => deps.markAudioCard(),
|
markAudioCard: () => deps.markAudioCard(),
|
||||||
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
|
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||||
|
|
||||||
|
test('separate settings windows suppress visible overlay restacking', () => {
|
||||||
|
const mainWindow = { id: 'overlay', isDestroyed: () => false };
|
||||||
|
const settingsWindow = { id: 'settings', isDestroyed: () => false };
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window: mainWindow,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: [settingsWindow],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('separate settings windows do not suppress unrelated or closed overlay work', () => {
|
||||||
|
const mainWindow = { id: 'overlay', isDestroyed: () => false };
|
||||||
|
const modalWindow = { id: 'modal', isDestroyed: () => false };
|
||||||
|
const closedSettingsWindow = { id: 'settings', isDestroyed: () => true };
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window: modalWindow,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: [{ isDestroyed: () => false }],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window: mainWindow,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: [closedSettingsWindow, null],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
type SeparateWindowLike = {
|
||||||
|
isDestroyed(): boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasLiveSeparateWindow(windows: Array<SeparateWindowLike | null | undefined>): boolean {
|
||||||
|
return windows.some((window) => Boolean(window && !window.isDestroyed()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: {
|
||||||
|
window: unknown;
|
||||||
|
mainWindow: unknown;
|
||||||
|
separateWindows: Array<SeparateWindowLike | null | undefined>;
|
||||||
|
}): boolean {
|
||||||
|
if (!options.mainWindow || options.window !== options.mainWindow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasLiveSeparateWindow(options.separateWindows);
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ test('createCreateConfigSettingsWindowHandler builds configuration settings wind
|
|||||||
width: 1040,
|
width: 1040,
|
||||||
height: 760,
|
height: 760,
|
||||||
title: 'SubMiner Settings',
|
title: 'SubMiner Settings',
|
||||||
show: true,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
backgroundColor: '#24273a',
|
backgroundColor: '#24273a',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ interface SetupWindowConfig {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
show?: boolean;
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
minimizable?: boolean;
|
minimizable?: boolean;
|
||||||
maximizable?: boolean;
|
maximizable?: boolean;
|
||||||
@@ -19,7 +20,7 @@ function createSetupWindowHandler<TWindow>(
|
|||||||
width: config.width,
|
width: config.width,
|
||||||
height: config.height,
|
height: config.height,
|
||||||
title: config.title,
|
title: config.title,
|
||||||
show: true,
|
show: config.show ?? true,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
|
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
|
||||||
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
|
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
|
||||||
@@ -77,6 +78,7 @@ export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
|
|||||||
width: 1040,
|
width: 1040,
|
||||||
height: 760,
|
height: 760,
|
||||||
title: 'SubMiner Settings',
|
title: 'SubMiner Settings',
|
||||||
|
show: false,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
preloadPath: deps.preloadPath,
|
preloadPath: deps.preloadPath,
|
||||||
backgroundColor: '#24273a',
|
backgroundColor: '#24273a',
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
|||||||
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
||||||
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
|
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
|
||||||
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
|
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
|
||||||
|
getCurrentCharacterDictionaryMediaId?: NonNullable<
|
||||||
|
TokenizerDepsRuntimeOptions['getCurrentCharacterDictionaryMediaId']
|
||||||
|
>;
|
||||||
getFrequencyDictionaryEnabled: NonNullable<
|
getFrequencyDictionaryEnabled: NonNullable<
|
||||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||||
>;
|
>;
|
||||||
@@ -70,6 +73,11 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
|
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(deps.getCurrentCharacterDictionaryMediaId
|
||||||
|
? {
|
||||||
|
getCurrentCharacterDictionaryMediaId: () => deps.getCurrentCharacterDictionaryMediaId!(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ test('build tray template handler wires actions and init guards', () => {
|
|||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => true,
|
showFirstRunSetup: () => true,
|
||||||
openFirstRunSetupWindow: (force?: boolean) =>
|
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||||
calls.push(force ? 'setup-forced' : 'setup'),
|
|
||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
@@ -118,8 +117,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
|
|||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => false,
|
showFirstRunSetup: () => false,
|
||||||
openFirstRunSetupWindow: (force?: boolean) =>
|
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||||
calls.push(force ? 'setup-forced' : 'setup'),
|
|
||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ test('tray main deps builders return mapped handlers', () => {
|
|||||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||||
showTexthookerPage: () => true,
|
showTexthookerPage: () => true,
|
||||||
showFirstRunSetup: () => true,
|
showFirstRunSetup: () => true,
|
||||||
openFirstRunSetupWindow: (force?: boolean) =>
|
openFirstRunSetupWindow: (force?: boolean) => calls.push(force ? 'setup-forced' : 'setup'),
|
||||||
calls.push(force ? 'setup-forced' : 'setup'),
|
|
||||||
showWindowsMpvLauncherSetup: () => true,
|
showWindowsMpvLauncherSetup: () => true,
|
||||||
openYomitanSettings: () => calls.push('yomitan'),
|
openYomitanSettings: () => calls.push('yomitan'),
|
||||||
openConfigSettingsWindow: () => calls.push('configuration'),
|
openConfigSettingsWindow: () => calls.push('configuration'),
|
||||||
|
|||||||
+25
-2
@@ -159,6 +159,9 @@ const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessio
|
|||||||
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.characterDictionaryOpen,
|
IPC_CHANNELS.event.characterDictionaryOpen,
|
||||||
);
|
);
|
||||||
|
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
|
||||||
|
IPC_CHANNELS.event.characterDictionaryManagerOpen,
|
||||||
|
);
|
||||||
const onOpenControllerSelectEvent = createQueuedIpcListener(
|
const onOpenControllerSelectEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.controllerSelectOpen,
|
IPC_CHANNELS.event.controllerSelectOpen,
|
||||||
);
|
);
|
||||||
@@ -388,6 +391,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||||
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
|
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
|
||||||
|
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||||
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
|
||||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||||
@@ -415,8 +419,27 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
||||||
getCharacterDictionarySelection: (searchTitle?: string) =>
|
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
|
||||||
setCharacterDictionarySelection: (mediaId: number) =>
|
setCharacterDictionarySelection: (
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
|
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) => {
|
notifyOverlayModalClosed: (modal) => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
|||||||
multiCopyTimeoutMs: 3000,
|
multiCopyTimeoutMs: 3000,
|
||||||
toggleSecondarySub: null,
|
toggleSecondarySub: null,
|
||||||
markAudioCard: null,
|
markAudioCard: null,
|
||||||
openCharacterDictionary: null,
|
openCharacterDictionaryManager: null,
|
||||||
openRuntimeOptions: null,
|
openRuntimeOptions: null,
|
||||||
openJimaku: null,
|
openJimaku: null,
|
||||||
openSessionHelp: null,
|
openSessionHelp: null,
|
||||||
|
|||||||
+47
-15
@@ -200,29 +200,61 @@
|
|||||||
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
|
<div id="characterDictionaryModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content character-dictionary-content">
|
<div class="modal-content character-dictionary-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<div class="modal-title">Character Dictionary Anime</div>
|
<div class="modal-title">Character Dictionary Management</div>
|
||||||
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
|
<button id="characterDictionaryClose" class="modal-close" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
<div
|
||||||
<div class="character-dictionary-search">
|
class="character-dictionary-tabs"
|
||||||
<input
|
role="tablist"
|
||||||
id="characterDictionarySearchInput"
|
aria-label="Character dictionary views"
|
||||||
class="character-dictionary-search-input"
|
>
|
||||||
type="text"
|
|
||||||
aria-label="Search character dictionary"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
id="characterDictionarySearchButton"
|
id="characterDictionaryOverrideTab"
|
||||||
class="character-dictionary-use"
|
class="character-dictionary-tab active"
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="true"
|
||||||
>
|
>
|
||||||
Search
|
Override
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="characterDictionaryManageTab"
|
||||||
|
class="character-dictionary-tab"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||||
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
<div id="characterDictionarySearchPanel">
|
||||||
|
<div class="character-dictionary-search">
|
||||||
|
<input
|
||||||
|
id="characterDictionarySearchInput"
|
||||||
|
class="character-dictionary-search-input"
|
||||||
|
type="text"
|
||||||
|
aria-label="Search character dictionary"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="characterDictionarySearchButton"
|
||||||
|
class="character-dictionary-use"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
||||||
|
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
||||||
|
</div>
|
||||||
|
<div id="characterDictionaryManagerPanel" class="hidden">
|
||||||
|
<ul
|
||||||
|
id="characterDictionaryManagedEntries"
|
||||||
|
class="character-dictionary-candidates character-dictionary-managed-entries"
|
||||||
|
></ul>
|
||||||
|
</div>
|
||||||
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function createClassList(initialTokens: string[] = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createElementStub() {
|
function createElementStub() {
|
||||||
|
const listeners = new Map<string, Array<(event?: { stopPropagation?: () => void }) => void>>();
|
||||||
return {
|
return {
|
||||||
className: '',
|
className: '',
|
||||||
textContent: '',
|
textContent: '',
|
||||||
@@ -35,7 +36,15 @@ function createElementStub() {
|
|||||||
append(...children: unknown[]) {
|
append(...children: unknown[]) {
|
||||||
this.children.push(...children);
|
this.children.push(...children);
|
||||||
},
|
},
|
||||||
addEventListener: () => {},
|
addEventListener: (
|
||||||
|
event: string,
|
||||||
|
listener: (event?: { stopPropagation?: () => void }) => void,
|
||||||
|
) => {
|
||||||
|
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||||
|
},
|
||||||
|
dispatchEvent: (event: string, payload?: { stopPropagation?: () => void }) => {
|
||||||
|
for (const listener of listeners.get(event) ?? []) listener(payload);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +166,299 @@ test('character dictionary modal announces open before AniList refresh resolves'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('character dictionary modal opens manager view with active entries', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
const calls: string[] = [];
|
||||||
|
const overlay = createNodeStub();
|
||||||
|
const modalNode = createNodeStub(true);
|
||||||
|
const managedEntries = createNodeStub();
|
||||||
|
const summary = createNodeStub();
|
||||||
|
const state = createRendererState();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionaryManagerSnapshot: async () => {
|
||||||
|
calls.push('snapshot');
|
||||||
|
return {
|
||||||
|
entries: [
|
||||||
|
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
|
||||||
|
{
|
||||||
|
mediaId: 115230,
|
||||||
|
label: '115230 - Tower of God',
|
||||||
|
title: 'Tower of God',
|
||||||
|
current: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
|
||||||
|
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
|
||||||
|
getCharacterDictionarySelection: async () => ({
|
||||||
|
seriesKey: '',
|
||||||
|
guessTitle: null,
|
||||||
|
current: null,
|
||||||
|
override: null,
|
||||||
|
candidates: [],
|
||||||
|
}),
|
||||||
|
setCharacterDictionarySelection: async () => ({
|
||||||
|
ok: false,
|
||||||
|
seriesKey: '',
|
||||||
|
selected: { id: 0, title: '', episodes: null },
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createElementStub(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay,
|
||||||
|
characterDictionaryModal: modalNode,
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: summary,
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
|
characterDictionaryCandidates: createNodeStub(),
|
||||||
|
characterDictionaryStatus: createNodeStub(),
|
||||||
|
characterDictionarySearchPanel: createNodeStub(),
|
||||||
|
characterDictionaryManagerPanel: createNodeStub(true),
|
||||||
|
characterDictionaryOverrideTab: createNodeStub(),
|
||||||
|
characterDictionaryManageTab: createNodeStub(),
|
||||||
|
characterDictionaryManagedEntries: managedEntries,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
) as ReturnType<typeof createCharacterDictionaryModal> & {
|
||||||
|
openCharacterDictionaryManagerModal: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryManagerModal();
|
||||||
|
|
||||||
|
assert.equal(state.characterDictionaryModalOpen, true);
|
||||||
|
assert.deepEqual(calls, ['snapshot']);
|
||||||
|
assert.equal(managedEntries.children.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
summary.textContent,
|
||||||
|
'2 loaded character dictionaries. Order controls eviction priority; current dictionary stays loaded.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('character dictionary manager reports failed reorder IPC calls', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
const overlay = createNodeStub();
|
||||||
|
const modalNode = createNodeStub(true);
|
||||||
|
const managedEntries = createNodeStub();
|
||||||
|
const status = createNodeStub();
|
||||||
|
const state = createRendererState();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionaryManagerSnapshot: async () => ({
|
||||||
|
entries: [
|
||||||
|
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
|
||||||
|
{
|
||||||
|
mediaId: 115230,
|
||||||
|
label: '115230 - Tower of God',
|
||||||
|
title: 'Tower of God',
|
||||||
|
current: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
moveCharacterDictionaryManagedEntry: async () => {
|
||||||
|
throw new Error('move failed');
|
||||||
|
},
|
||||||
|
removeCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
|
||||||
|
getCharacterDictionarySelection: async () => ({
|
||||||
|
seriesKey: '',
|
||||||
|
guessTitle: null,
|
||||||
|
current: null,
|
||||||
|
override: null,
|
||||||
|
candidates: [],
|
||||||
|
}),
|
||||||
|
setCharacterDictionarySelection: async () => ({
|
||||||
|
ok: false,
|
||||||
|
seriesKey: '',
|
||||||
|
selected: { id: 0, title: '', episodes: null },
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createElementStub(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay,
|
||||||
|
characterDictionaryModal: modalNode,
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: createNodeStub(),
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
|
characterDictionaryCandidates: createNodeStub(),
|
||||||
|
characterDictionaryStatus: status,
|
||||||
|
characterDictionarySearchPanel: createNodeStub(),
|
||||||
|
characterDictionaryManagerPanel: createNodeStub(true),
|
||||||
|
characterDictionaryOverrideTab: createNodeStub(),
|
||||||
|
characterDictionaryManageTab: createNodeStub(),
|
||||||
|
characterDictionaryManagedEntries: managedEntries,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryManagerModal();
|
||||||
|
const secondEntry = managedEntries.children[1] as { children: unknown[] };
|
||||||
|
const controls = secondEntry.children[1] as {
|
||||||
|
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
|
||||||
|
};
|
||||||
|
controls.children[0]?.dispatchEvent('click', { stopPropagation: () => {} });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(status.textContent, 'move failed');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('character dictionary manager reports pending refresh after removal', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
const overlay = createNodeStub();
|
||||||
|
const modalNode = createNodeStub(true);
|
||||||
|
const managedEntries = createNodeStub();
|
||||||
|
const status = createNodeStub();
|
||||||
|
const state = createRendererState();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionaryManagerSnapshot: async () => ({
|
||||||
|
entries: [
|
||||||
|
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
|
||||||
|
{
|
||||||
|
mediaId: 115230,
|
||||||
|
label: '115230 - Tower of God',
|
||||||
|
title: 'Tower of God',
|
||||||
|
current: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
moveCharacterDictionaryManagedEntry: async () => ({ ok: true, entries: [] }),
|
||||||
|
removeCharacterDictionaryManagedEntry: async () => ({
|
||||||
|
ok: true,
|
||||||
|
entries: [
|
||||||
|
{ mediaId: 21202, label: '21202 - KonoSuba', title: 'KonoSuba', current: true },
|
||||||
|
],
|
||||||
|
rebuildRequired: true,
|
||||||
|
}),
|
||||||
|
getCharacterDictionarySelection: async () => ({
|
||||||
|
seriesKey: '',
|
||||||
|
guessTitle: null,
|
||||||
|
current: null,
|
||||||
|
override: null,
|
||||||
|
candidates: [],
|
||||||
|
}),
|
||||||
|
setCharacterDictionarySelection: async () => ({
|
||||||
|
ok: false,
|
||||||
|
seriesKey: '',
|
||||||
|
selected: { id: 0, title: '', episodes: null },
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createElementStub(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay,
|
||||||
|
characterDictionaryModal: modalNode,
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: createNodeStub(),
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
|
characterDictionaryCandidates: createNodeStub(),
|
||||||
|
characterDictionaryStatus: status,
|
||||||
|
characterDictionarySearchPanel: createNodeStub(),
|
||||||
|
characterDictionaryManagerPanel: createNodeStub(true),
|
||||||
|
characterDictionaryOverrideTab: createNodeStub(),
|
||||||
|
characterDictionaryManageTab: createNodeStub(),
|
||||||
|
characterDictionaryManagedEntries: managedEntries,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryManagerModal();
|
||||||
|
const secondEntry = managedEntries.children[1] as { children: unknown[] };
|
||||||
|
const controls = secondEntry.children[1] as {
|
||||||
|
children: Array<{ dispatchEvent: (event: string, payload?: unknown) => void }>;
|
||||||
|
};
|
||||||
|
controls.children[3]?.dispatchEvent('click', { stopPropagation: () => {} });
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.equal(status.textContent, 'Entry removed. Merged dictionary will refresh shortly.');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('character dictionary modal loads candidates and applies selected override', async () => {
|
test('character dictionary modal loads candidates and applies selected override', async () => {
|
||||||
const previousWindow = globalThis.window;
|
const previousWindow = globalThis.window;
|
||||||
const previousDocument = globalThis.document;
|
const previousDocument = globalThis.document;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
CharacterDictionaryCandidate,
|
CharacterDictionaryCandidate,
|
||||||
|
CharacterDictionaryManagerEntry,
|
||||||
|
CharacterDictionaryManagerSnapshot,
|
||||||
CharacterDictionarySelectionSnapshot,
|
CharacterDictionarySelectionSnapshot,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
type CharacterDictionaryView = 'override' | 'manage';
|
||||||
|
|
||||||
function clampIndex(index: number, length: number): number {
|
function clampIndex(index: number, length: number): number {
|
||||||
if (length <= 0) return 0;
|
if (length <= 0) return 0;
|
||||||
return Math.min(Math.max(index, 0), length - 1);
|
return Math.min(Math.max(index, 0), length - 1);
|
||||||
@@ -28,6 +32,9 @@ export function createCharacterDictionaryModal(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let hasSearched = false;
|
let hasSearched = false;
|
||||||
|
let activeView: CharacterDictionaryView = 'override';
|
||||||
|
let managerSnapshot: CharacterDictionaryManagerSnapshot | null = null;
|
||||||
|
let pendingManagedOverride: { mediaId: number; title: string } | null = null;
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
function setStatus(message: string, isError = false): void {
|
||||||
ctx.state.characterDictionaryStatus = message;
|
ctx.state.characterDictionaryStatus = message;
|
||||||
@@ -54,6 +61,22 @@ export function createCharacterDictionaryModal(
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setActiveView(view: CharacterDictionaryView): void {
|
||||||
|
activeView = view;
|
||||||
|
ctx.dom.characterDictionarySearchPanel?.classList.toggle('hidden', view !== 'override');
|
||||||
|
ctx.dom.characterDictionaryManagerPanel?.classList.toggle('hidden', view !== 'manage');
|
||||||
|
ctx.dom.characterDictionaryOverrideTab?.classList.toggle('active', view === 'override');
|
||||||
|
ctx.dom.characterDictionaryManageTab?.classList.toggle('active', view === 'manage');
|
||||||
|
ctx.dom.characterDictionaryOverrideTab?.setAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
view === 'override' ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
ctx.dom.characterDictionaryManageTab?.setAttribute(
|
||||||
|
'aria-selected',
|
||||||
|
view === 'manage' ? 'true' : 'false',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
||||||
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
|
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
@@ -127,6 +150,84 @@ export function createCharacterDictionaryModal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderManagerEntry(
|
||||||
|
entry: CharacterDictionaryManagerEntry,
|
||||||
|
index: number,
|
||||||
|
entryCount: number,
|
||||||
|
): HTMLLIElement {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'character-dictionary-candidate character-dictionary-managed-entry';
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'runtime-options-label';
|
||||||
|
main.textContent = entry.title || entry.label;
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'runtime-options-allowed';
|
||||||
|
meta.textContent = `AniList ${entry.mediaId}${entry.current ? ' · Current' : ''}`;
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'character-dictionary-candidate-body';
|
||||||
|
body.append(main, meta);
|
||||||
|
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'character-dictionary-manager-actions';
|
||||||
|
|
||||||
|
const makeButton = (
|
||||||
|
label: string,
|
||||||
|
disabled: boolean,
|
||||||
|
onClick: () => void | Promise<void>,
|
||||||
|
): HTMLButtonElement => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'character-dictionary-use';
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = label;
|
||||||
|
button.disabled = disabled;
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (button.disabled) return;
|
||||||
|
void onClick();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
controls.append(
|
||||||
|
makeButton('Up', entry.current || index === 0, () => moveManagedEntry(entry.mediaId, -1)),
|
||||||
|
makeButton('Down', entry.current || index >= entryCount - 1, () =>
|
||||||
|
moveManagedEntry(entry.mediaId, 1),
|
||||||
|
),
|
||||||
|
makeButton('Override', false, () => openManagedOverride(entry)),
|
||||||
|
makeButton('Remove', entry.current, () => removeManagedEntry(entry.mediaId)),
|
||||||
|
);
|
||||||
|
|
||||||
|
item.append(body, controls);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderManager(): void {
|
||||||
|
const entries = managerSnapshot?.entries ?? [];
|
||||||
|
ctx.dom.characterDictionaryManagedEntries?.replaceChildren();
|
||||||
|
if (!ctx.dom.characterDictionaryManagedEntries) return;
|
||||||
|
|
||||||
|
ctx.dom.characterDictionarySummary.textContent =
|
||||||
|
entries.length > 0
|
||||||
|
? `${entries.length} loaded character dictionaries. Order controls eviction priority; current dictionary stays loaded.`
|
||||||
|
: 'No loaded character dictionaries.';
|
||||||
|
ctx.dom.characterDictionaryCurrent.textContent = '';
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement('li');
|
||||||
|
empty.className = 'character-dictionary-empty';
|
||||||
|
empty.textContent = 'No loaded character dictionaries.';
|
||||||
|
ctx.dom.characterDictionaryManagedEntries.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.dom.characterDictionaryManagedEntries.replaceChildren(
|
||||||
|
...entries.map((entry, index) => renderManagerEntry(entry, index, entries.length)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshSelection(searchTitle?: string): Promise<void> {
|
async function refreshSelection(searchTitle?: string): Promise<void> {
|
||||||
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
|
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
|
||||||
hasSearched = searchTitle !== '';
|
hasSearched = searchTitle !== '';
|
||||||
@@ -140,6 +241,12 @@ export function createCharacterDictionaryModal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshManager(): Promise<void> {
|
||||||
|
managerSnapshot = await window.electronAPI.getCharacterDictionaryManagerSnapshot();
|
||||||
|
renderManager();
|
||||||
|
setStatus('Loaded character dictionary entries.');
|
||||||
|
}
|
||||||
|
|
||||||
async function searchCandidates(): Promise<void> {
|
async function searchCandidates(): Promise<void> {
|
||||||
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
|
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
|
||||||
if (!searchTitle) {
|
if (!searchTitle) {
|
||||||
@@ -165,17 +272,80 @@ export function createCharacterDictionaryModal(
|
|||||||
|
|
||||||
setStatus(`Saving override for ${candidate.title}...`);
|
setStatus(`Saving override for ${candidate.title}...`);
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.setCharacterDictionarySelection(candidate.id);
|
const result = await window.electronAPI.setCharacterDictionarySelection(
|
||||||
|
candidate.id,
|
||||||
|
pendingManagedOverride?.mediaId,
|
||||||
|
pendingManagedOverride ? candidate.title : undefined,
|
||||||
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setStatus('Failed to save override', true);
|
setStatus('message' in result ? result.message : 'Failed to save override', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingManagedOverride) {
|
||||||
|
const replacedTitle = candidate.title;
|
||||||
|
pendingManagedOverride = null;
|
||||||
|
await refreshManager();
|
||||||
|
setActiveView('manage');
|
||||||
|
setStatus(`Managed entry replaced with ${replacedTitle}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
|
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
|
||||||
const staleLabel =
|
if ('selected' in result) {
|
||||||
result.staleMediaIds.length > 0
|
const staleLabel =
|
||||||
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
result.staleMediaIds.length > 0
|
||||||
: '';
|
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
||||||
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
|
: '';
|
||||||
|
setStatus(`Override saved: ${formatCandidate(result.selected)}.${staleLabel}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveManagedEntry(mediaId: number, direction: 1 | -1): Promise<void> {
|
||||||
|
setStatus('Updating entry order...');
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.moveCharacterDictionaryManagedEntry(
|
||||||
|
mediaId,
|
||||||
|
direction,
|
||||||
|
);
|
||||||
|
managerSnapshot = { entries: result.entries };
|
||||||
|
renderManager();
|
||||||
|
setStatus(result.ok ? 'Entry order updated.' : result.message, !result.ok);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeManagedEntry(mediaId: number): Promise<void> {
|
||||||
|
setStatus('Removing entry...');
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.removeCharacterDictionaryManagedEntry(mediaId);
|
||||||
|
managerSnapshot = { entries: result.entries };
|
||||||
|
renderManager();
|
||||||
|
setStatus(
|
||||||
|
result.ok
|
||||||
|
? result.rebuildRequired
|
||||||
|
? 'Entry removed. Merged dictionary will refresh shortly.'
|
||||||
|
: 'Entry removed.'
|
||||||
|
: result.message,
|
||||||
|
!result.ok,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openManagedOverride(entry: CharacterDictionaryManagerEntry): Promise<void> {
|
||||||
|
pendingManagedOverride = entry.current
|
||||||
|
? null
|
||||||
|
: { mediaId: entry.mediaId, title: entry.title || entry.label };
|
||||||
|
setActiveView('override');
|
||||||
|
const searchTitle = entry.title || entry.label;
|
||||||
|
ctx.dom.characterDictionarySearchInput.value = searchTitle;
|
||||||
|
setStatus(`Searching AniList for ${searchTitle}...`);
|
||||||
|
try {
|
||||||
|
await refreshSelection(searchTitle);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
}
|
}
|
||||||
@@ -192,6 +362,8 @@ export function createCharacterDictionaryModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCharacterDictionaryModal(): Promise<void> {
|
async function openCharacterDictionaryModal(): Promise<void> {
|
||||||
|
setActiveView('override');
|
||||||
|
pendingManagedOverride = null;
|
||||||
if (!ctx.state.characterDictionaryModalOpen) {
|
if (!ctx.state.characterDictionaryModalOpen) {
|
||||||
showShell();
|
showShell();
|
||||||
} else {
|
} else {
|
||||||
@@ -205,14 +377,33 @@ export function createCharacterDictionaryModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openCharacterDictionaryManagerModal(): Promise<void> {
|
||||||
|
setActiveView('manage');
|
||||||
|
pendingManagedOverride = null;
|
||||||
|
if (!ctx.state.characterDictionaryModalOpen) {
|
||||||
|
showShell();
|
||||||
|
} else {
|
||||||
|
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
|
||||||
|
setStatus('Refreshing character dictionary entries...');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshManager();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeCharacterDictionaryModal(): void {
|
function closeCharacterDictionaryModal(): void {
|
||||||
if (!ctx.state.characterDictionaryModalOpen) return;
|
if (!ctx.state.characterDictionaryModalOpen) return;
|
||||||
ctx.state.characterDictionaryModalOpen = false;
|
ctx.state.characterDictionaryModalOpen = false;
|
||||||
ctx.state.characterDictionarySelection = null;
|
ctx.state.characterDictionarySelection = null;
|
||||||
|
managerSnapshot = null;
|
||||||
|
pendingManagedOverride = null;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
ctx.dom.characterDictionaryModal.classList.add('hidden');
|
ctx.dom.characterDictionaryModal.classList.add('hidden');
|
||||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
|
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'true');
|
||||||
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
ctx.dom.characterDictionaryCandidates.replaceChildren();
|
||||||
|
ctx.dom.characterDictionaryManagedEntries?.replaceChildren();
|
||||||
hasSearched = false;
|
hasSearched = false;
|
||||||
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
||||||
setStatus('');
|
setStatus('');
|
||||||
@@ -245,6 +436,9 @@ export function createCharacterDictionaryModal(
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (activeView === 'manage') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveSelection(1);
|
moveSelection(1);
|
||||||
@@ -265,6 +459,12 @@ export function createCharacterDictionaryModal(
|
|||||||
|
|
||||||
function wireDomEvents(): void {
|
function wireDomEvents(): void {
|
||||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||||
|
ctx.dom.characterDictionaryOverrideTab?.addEventListener('click', () => {
|
||||||
|
void openCharacterDictionaryModal();
|
||||||
|
});
|
||||||
|
ctx.dom.characterDictionaryManageTab?.addEventListener('click', () => {
|
||||||
|
void openCharacterDictionaryManagerModal();
|
||||||
|
});
|
||||||
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
|
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
|
||||||
void searchCandidates();
|
void searchCandidates();
|
||||||
});
|
});
|
||||||
@@ -278,6 +478,7 @@ export function createCharacterDictionaryModal(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
openCharacterDictionaryModal,
|
openCharacterDictionaryModal,
|
||||||
|
openCharacterDictionaryManagerModal,
|
||||||
closeCharacterDictionaryModal,
|
closeCharacterDictionaryModal,
|
||||||
handleCharacterDictionaryKeydown,
|
handleCharacterDictionaryKeydown,
|
||||||
wireDomEvents,
|
wireDomEvents,
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ function describeSessionAction(
|
|||||||
case 'openSessionHelp':
|
case 'openSessionHelp':
|
||||||
return 'Open session help';
|
return 'Open session help';
|
||||||
case 'openCharacterDictionary':
|
case 'openCharacterDictionary':
|
||||||
return 'Open character dictionary anime selector';
|
return 'Open AniList override selector';
|
||||||
|
case 'openCharacterDictionaryManager':
|
||||||
|
return 'Open character dictionary manager';
|
||||||
case 'openControllerSelect':
|
case 'openControllerSelect':
|
||||||
return 'Open controller select';
|
return 'Open controller select';
|
||||||
case 'openControllerDebug':
|
case 'openControllerDebug':
|
||||||
@@ -255,6 +257,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
|||||||
case 'openRuntimeOptions':
|
case 'openRuntimeOptions':
|
||||||
case 'openJimaku':
|
case 'openJimaku':
|
||||||
case 'openCharacterDictionary':
|
case 'openCharacterDictionary':
|
||||||
|
case 'openCharacterDictionaryManager':
|
||||||
case 'openControllerSelect':
|
case 'openControllerSelect':
|
||||||
case 'openControllerDebug':
|
case 'openControllerDebug':
|
||||||
case 'openYoutubePicker':
|
case 'openYoutubePicker':
|
||||||
|
|||||||
@@ -465,6 +465,11 @@ function registerModalOpenHandlers(): void {
|
|||||||
await characterDictionaryModal.openCharacterDictionaryModal();
|
await characterDictionaryModal.openCharacterDictionaryModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onOpenCharacterDictionaryManager(() => {
|
||||||
|
runGuardedAsync('character-dictionary-manager:open', async () => {
|
||||||
|
await characterDictionaryModal.openCharacterDictionaryManagerModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
window.electronAPI.onOpenSessionHelp(() => {
|
window.electronAPI.onOpenSessionHelp(() => {
|
||||||
runGuarded('session-help:open', () => {
|
runGuarded('session-help:open', () => {
|
||||||
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo());
|
||||||
|
|||||||
@@ -1568,6 +1568,27 @@ iframe[id^='yomitan-popup'],
|
|||||||
width: min(680px, 92%);
|
width: min(680px, 92%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-dictionary-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-dictionary-tab {
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.28);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(24, 25, 38, 0.78);
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-dictionary-tab.active {
|
||||||
|
border-color: rgba(138, 173, 244, 0.62);
|
||||||
|
background: rgba(138, 173, 244, 0.18);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
.character-dictionary-current {
|
.character-dictionary-current {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
@@ -1631,6 +1652,18 @@ iframe[id^='yomitan-popup'],
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-dictionary-manager-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-dictionary-managed-entry {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.character-dictionary-use {
|
.character-dictionary-use {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid rgba(138, 173, 244, 0.38);
|
border: 1px solid rgba(138, 173, 244, 0.38);
|
||||||
|
|||||||
@@ -59,11 +59,16 @@ export type RendererDom = {
|
|||||||
|
|
||||||
characterDictionaryModal: HTMLDivElement;
|
characterDictionaryModal: HTMLDivElement;
|
||||||
characterDictionaryClose: HTMLButtonElement;
|
characterDictionaryClose: HTMLButtonElement;
|
||||||
|
characterDictionaryOverrideTab: HTMLButtonElement;
|
||||||
|
characterDictionaryManageTab: HTMLButtonElement;
|
||||||
characterDictionarySummary: HTMLDivElement;
|
characterDictionarySummary: HTMLDivElement;
|
||||||
|
characterDictionarySearchPanel: HTMLDivElement;
|
||||||
characterDictionarySearchInput: HTMLInputElement;
|
characterDictionarySearchInput: HTMLInputElement;
|
||||||
characterDictionarySearchButton: HTMLButtonElement;
|
characterDictionarySearchButton: HTMLButtonElement;
|
||||||
characterDictionaryCurrent: HTMLDivElement;
|
characterDictionaryCurrent: HTMLDivElement;
|
||||||
characterDictionaryCandidates: HTMLUListElement;
|
characterDictionaryCandidates: HTMLUListElement;
|
||||||
|
characterDictionaryManagerPanel: HTMLDivElement;
|
||||||
|
characterDictionaryManagedEntries: HTMLUListElement;
|
||||||
characterDictionaryStatus: HTMLDivElement;
|
characterDictionaryStatus: HTMLDivElement;
|
||||||
|
|
||||||
subsyncModal: HTMLDivElement;
|
subsyncModal: HTMLDivElement;
|
||||||
@@ -188,7 +193,16 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
|
|
||||||
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
||||||
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
||||||
|
characterDictionaryOverrideTab: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'characterDictionaryOverrideTab',
|
||||||
|
),
|
||||||
|
characterDictionaryManageTab: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'characterDictionaryManageTab',
|
||||||
|
),
|
||||||
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
||||||
|
characterDictionarySearchPanel: getRequiredElement<HTMLDivElement>(
|
||||||
|
'characterDictionarySearchPanel',
|
||||||
|
),
|
||||||
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
|
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
|
||||||
'characterDictionarySearchInput',
|
'characterDictionarySearchInput',
|
||||||
),
|
),
|
||||||
@@ -199,6 +213,12 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
||||||
'characterDictionaryCandidates',
|
'characterDictionaryCandidates',
|
||||||
),
|
),
|
||||||
|
characterDictionaryManagerPanel: getRequiredElement<HTMLDivElement>(
|
||||||
|
'characterDictionaryManagerPanel',
|
||||||
|
),
|
||||||
|
characterDictionaryManagedEntries: getRequiredElement<HTMLUListElement>(
|
||||||
|
'characterDictionaryManagedEntries',
|
||||||
|
),
|
||||||
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
|
characterDictionaryStatus: getRequiredElement<HTMLDivElement>('characterDictionaryStatus'),
|
||||||
|
|
||||||
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
|
subsyncModal: getRequiredElement<HTMLDivElement>('subsyncModal'),
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export const IPC_CHANNELS = {
|
|||||||
jellyfinSetupSubmit: 'jellyfin:setup-submit',
|
jellyfinSetupSubmit: 'jellyfin:setup-submit',
|
||||||
getCharacterDictionarySelection: 'character-dictionary:get-selection',
|
getCharacterDictionarySelection: 'character-dictionary:get-selection',
|
||||||
setCharacterDictionarySelection: 'character-dictionary:set-selection',
|
setCharacterDictionarySelection: 'character-dictionary:set-selection',
|
||||||
|
getCharacterDictionaryManagerSnapshot: 'character-dictionary:get-manager-snapshot',
|
||||||
|
removeCharacterDictionaryManagedEntry: 'character-dictionary:remove-managed-entry',
|
||||||
|
moveCharacterDictionaryManagedEntry: 'character-dictionary:move-managed-entry',
|
||||||
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
|
||||||
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
|
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
|
||||||
appendPlaylistBrowserFile: 'playlist-browser:append-file',
|
appendPlaylistBrowserFile: 'playlist-browser:append-file',
|
||||||
@@ -131,6 +134,7 @@ export const IPC_CHANNELS = {
|
|||||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||||
sessionHelpOpen: 'session-help:open',
|
sessionHelpOpen: 'session-help:open',
|
||||||
characterDictionaryOpen: 'character-dictionary:open',
|
characterDictionaryOpen: 'character-dictionary:open',
|
||||||
|
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
|
||||||
controllerSelectOpen: 'controller-select:open',
|
controllerSelectOpen: 'controller-select:open',
|
||||||
controllerDebugOpen: 'controller-debug:open',
|
controllerDebugOpen: 'controller-debug:open',
|
||||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
|||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openSessionHelp',
|
'openSessionHelp',
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionary',
|
||||||
|
'openCharacterDictionaryManager',
|
||||||
'openControllerSelect',
|
'openControllerSelect',
|
||||||
'openControllerDebug',
|
'openControllerDebug',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ export interface ShortcutsConfig {
|
|||||||
multiCopyTimeoutMs?: number;
|
multiCopyTimeoutMs?: number;
|
||||||
toggleSecondarySub?: string | null;
|
toggleSecondarySub?: string | null;
|
||||||
markAudioCard?: string | null;
|
markAudioCard?: string | null;
|
||||||
openCharacterDictionary?: string | null;
|
openCharacterDictionaryManager?: string | null;
|
||||||
openRuntimeOptions?: string | null;
|
openRuntimeOptions?: string | null;
|
||||||
openJimaku?: string | null;
|
openJimaku?: string | null;
|
||||||
openSessionHelp?: string | null;
|
openSessionHelp?: string | null;
|
||||||
|
|||||||
+29
-1
@@ -379,6 +379,21 @@ export interface CharacterDictionarySelectionResult {
|
|||||||
staleMediaIds: number[];
|
staleMediaIds: 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 SessionNumericSelectionStartPayload {
|
export interface SessionNumericSelectionStartPayload {
|
||||||
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
actionId: Extract<SessionActionId, 'copySubtitleMultiple' | 'mineSentenceMultiple'>;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
@@ -454,6 +469,7 @@ export interface ElectronAPI {
|
|||||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||||
onOpenCharacterDictionary: (callback: () => void) => void;
|
onOpenCharacterDictionary: (callback: () => void) => void;
|
||||||
|
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||||
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
onPrimarySubtitleBarToggle: (callback: () => void) => void;
|
||||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||||
@@ -477,7 +493,19 @@ export interface ElectronAPI {
|
|||||||
getCharacterDictionarySelection: (
|
getCharacterDictionarySelection: (
|
||||||
searchTitle?: string,
|
searchTitle?: string,
|
||||||
) => Promise<CharacterDictionarySelectionSnapshot>;
|
) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||||
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
|
setCharacterDictionarySelection: (
|
||||||
|
mediaId: number,
|
||||||
|
replaceManagedMediaId?: number,
|
||||||
|
mediaTitle?: string,
|
||||||
|
) => Promise<CharacterDictionarySelectionResult | CharacterDictionaryManagerMutationResult>;
|
||||||
|
getCharacterDictionaryManagerSnapshot: () => Promise<CharacterDictionaryManagerSnapshot>;
|
||||||
|
removeCharacterDictionaryManagedEntry: (
|
||||||
|
mediaId: number,
|
||||||
|
) => Promise<CharacterDictionaryManagerMutationResult>;
|
||||||
|
moveCharacterDictionaryManagedEntry: (
|
||||||
|
mediaId: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
) => Promise<CharacterDictionaryManagerMutationResult>;
|
||||||
notifyOverlayModalClosed: (
|
notifyOverlayModalClosed: (
|
||||||
modal:
|
modal:
|
||||||
| 'runtime-options'
|
| 'runtime-options'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type SessionActionId =
|
|||||||
| 'openRuntimeOptions'
|
| 'openRuntimeOptions'
|
||||||
| 'openSessionHelp'
|
| 'openSessionHelp'
|
||||||
| 'openCharacterDictionary'
|
| 'openCharacterDictionary'
|
||||||
|
| 'openCharacterDictionaryManager'
|
||||||
| 'openControllerSelect'
|
| 'openControllerSelect'
|
||||||
| 'openControllerDebug'
|
| 'openControllerDebug'
|
||||||
| 'openJimaku'
|
| 'openJimaku'
|
||||||
|
|||||||
Reference in New Issue
Block a user