mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Add inline character portraits and dictionary search workflow (#83)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Keep character dictionary lookup entries scoped to generated Japanese name aliases instead of surfacing raw romanized/English aliases as separate results, and refresh cached v15 snapshots so old English-name entries are regenerated.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- **Character Dictionary:** Changed the in-app AniList selector to wait for an explicit title search. The search box is prefilled from the current filename guess, so you can edit it before choosing an override.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: added
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Added optional inline AniList portraits for character-name subtitle matches, including automatic refresh of cached character dictionary snapshots that do not contain portrait data.
|
||||||
|
- Scoped manual AniList overrides by parent media directory, so separate season folders can keep separate character dictionary selections.
|
||||||
|
- Fixed large character dictionary imports by serving the merged ZIP through a local URL when supported, with a base64 fallback for older bundled Yomitan builds.
|
||||||
|
- Allowed subtitle overlay data image sources so inline character portraits render instead of showing a broken image icon.
|
||||||
@@ -384,6 +384,7 @@
|
|||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
|
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
|
|||||||
@@ -91,21 +91,26 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
|
|||||||
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. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||||
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
||||||
|
5. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------------------------- | --------- | ---------------------------------- |
|
| -------------------------------------- | --------- | ----------------------------------------- |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
||||||
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||||
|
|
||||||
## Dictionary Entries
|
## Dictionary Entries
|
||||||
|
|
||||||
Each character entry in the Yomitan dictionary includes structured content:
|
Each character entry in the Yomitan dictionary includes structured content:
|
||||||
|
|
||||||
- **Name** — native (Japanese) and romanized forms
|
- **Name** — the matched Japanese name form
|
||||||
|
- **Known names** — generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
|
||||||
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
|
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
|
||||||
- **Portrait** — character image from AniList, embedded in the ZIP
|
- **Portrait** — character image from AniList, embedded in the ZIP
|
||||||
- **Description** — biography text from AniList (collapsible)
|
- **Description** — biography text from AniList (collapsible)
|
||||||
@@ -169,10 +174,13 @@ This creates a standalone dictionary ZIP for the target media and saves it along
|
|||||||
|
|
||||||
## Correcting AniList Matches
|
## Correcting AniList Matches
|
||||||
|
|
||||||
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
||||||
|
|
||||||
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.
|
||||||
|
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List candidate AniList matches for a file
|
# List candidate AniList matches for a file
|
||||||
subminer dictionary --candidates "/path/to/episode.mkv"
|
subminer dictionary --candidates "/path/to/episode.mkv"
|
||||||
@@ -188,7 +196,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --open-character-dictionary
|
subminer app --open-character-dictionary
|
||||||
```
|
```
|
||||||
|
|
||||||
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. 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.
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
@@ -207,7 +215,7 @@ character-dictionaries/
|
|||||||
m170942-va67890.jpg # Voice actor portrait
|
m170942-va67890.jpg # Voice actor portrait
|
||||||
```
|
```
|
||||||
|
|
||||||
**Snapshot format** (v15): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
|
**Snapshot format** (v16): 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:
|
||||||
|
|
||||||
@@ -231,6 +239,7 @@ merged.zip
|
|||||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
|
||||||
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||||
|
|
||||||
## Reference Implementation
|
## Reference Implementation
|
||||||
@@ -253,8 +262,9 @@ If you work with visual novels or want a standalone dictionary generator indepen
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **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.
|
||||||
- **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`) or 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 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`.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
@@ -387,6 +387,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
|
||||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
|
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
|
||||||
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
@@ -420,6 +421,7 @@ In `single` mode all highlights use `singleColor`; in `banded` mode tokens map t
|
|||||||
Character-name highlighting is separate from N+1 and frequency highlighting:
|
Character-name highlighting is separate from N+1 and frequency highlighting:
|
||||||
|
|
||||||
- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling.
|
- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling.
|
||||||
|
- `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots.
|
||||||
- `nameMatchColor` sets the highlight color for those matched character names.
|
- `nameMatchColor` sets the highlight color for those matched character names.
|
||||||
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled.
|
- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled.
|
||||||
|
|
||||||
@@ -866,7 +868,7 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- |
|
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||||
| `apiKey` | string | Static API key for the shared provider |
|
| `apiKey` | string | Static API key for the shared provider |
|
||||||
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
||||||
@@ -1126,7 +1128,7 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
|
|||||||
@@ -384,6 +384,7 @@
|
|||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
|
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ Character-name matches are built from the active merged SubMiner character dicti
|
|||||||
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
|
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
|
||||||
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
|
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
|
||||||
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
|
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
|
||||||
|
4. When `subtitleStyle.nameMatchImagesEnabled` is also enabled, SubMiner shows the cached AniList portrait beside matched names.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------------------------- | --------- | ---------------------------------------- |
|
| -------------------------------------- | --------- | ------------------------------------------------ |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
||||||
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits next to name tokens |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
||||||
|
|
||||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||||
@@ -68,7 +70,7 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
|||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------------------------------ | ------------ | ---------------------------------------- |
|
| ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
|
||||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
||||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||||
@@ -122,6 +124,7 @@ All annotation layers can be toggled at runtime via the mpv command menu without
|
|||||||
|
|
||||||
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
|
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
|
||||||
|
- `subtitleStyle.nameMatchImagesEnabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||||
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||||
assert.equal(config.anilist.enabled, false);
|
assert.equal(config.anilist.enabled, false);
|
||||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||||
|
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
|
||||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
@@ -740,6 +741,44 @@ test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"nameMatchImagesEnabled": true
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"nameMatchImagesEnabled": "yes"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses anilist.enabled and warns for invalid value', () => {
|
test('parses anilist.enabled and warns for invalid value', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'transparent',
|
hoverTokenBackgroundColor: 'transparent',
|
||||||
nameMatchEnabled: false,
|
nameMatchEnabled: false,
|
||||||
|
nameMatchImagesEnabled: false,
|
||||||
nameMatchColor: '#f5bde6',
|
nameMatchColor: '#f5bde6',
|
||||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
fontSize: 35,
|
fontSize: 35,
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
|
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'subtitleStyle.nameMatchImagesEnabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
description:
|
||||||
|
'Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.nameMatchColor',
|
path: 'subtitleStyle.nameMatchColor',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||||
|
const fallbackSubtitleStyleNameMatchImagesEnabled =
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled;
|
||||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||||
@@ -390,6 +392,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameMatchImagesEnabled = asBoolean(
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||||
|
);
|
||||||
|
if (nameMatchImagesEnabled !== undefined) {
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled = nameMatchImagesEnabled;
|
||||||
|
} else if (
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled !==
|
||||||
|
undefined
|
||||||
|
) {
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled = fallbackSubtitleStyleNameMatchImagesEnabled;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.nameMatchImagesEnabled',
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (nameMatchColor !== undefined) {
|
if (nameMatchColor !== undefined) {
|
||||||
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
||||||
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
||||||
|
|||||||
@@ -172,6 +172,31 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle nameMatchImagesEnabled accepts boolean and warns on invalid', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nameMatchImagesEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.nameMatchImagesEnabled, true);
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nameMatchImagesEnabled: 'yes' as unknown as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.equal(invalid.context.resolved.subtitleStyle.nameMatchImagesEnabled, false);
|
||||||
|
assert.ok(
|
||||||
|
invalid.warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.nameMatchImagesEnabled' &&
|
||||||
|
warning.message === 'Expected boolean.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
||||||
const { context } = createResolveContext({});
|
const { context } = createResolveContext({});
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ test('settings registry exposes css declaration editor for primary and secondary
|
|||||||
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
||||||
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.nameMatchImagesEnabled').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.knownWordColor' ||
|
path === 'subtitleStyle.knownWordColor' ||
|
||||||
path === 'subtitleStyle.nPlusOneColor' ||
|
path === 'subtitleStyle.nPlusOneColor' ||
|
||||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||||
path === 'subtitleStyle.nameMatchColor'
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
) {
|
) {
|
||||||
return { category: 'appearance', section: 'Annotation Display' };
|
return { category: 'appearance', section: 'Annotation Display' };
|
||||||
@@ -524,7 +525,11 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
) {
|
) {
|
||||||
return 'Frequency Highlighting';
|
return 'Frequency Highlighting';
|
||||||
}
|
}
|
||||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
if (
|
||||||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
|
) {
|
||||||
return 'Character Names';
|
return 'Character Names';
|
||||||
}
|
}
|
||||||
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||||
|
|||||||
@@ -1191,10 +1191,13 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
|
test('registerIpcHandlers exposes character dictionary selection handlers', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
|
const searches: Array<string | undefined> = [];
|
||||||
|
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
createRegisterIpcDeps({
|
createRegisterIpcDeps({
|
||||||
getCharacterDictionarySelection: async () => ({
|
getCharacterDictionarySelection: async (searchTitle) => {
|
||||||
|
searches.push(searchTitle);
|
||||||
|
return {
|
||||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||||
@@ -1202,7 +1205,8 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
|
|||||||
candidates: [
|
candidates: [
|
||||||
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||||
],
|
],
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
setCharacterDictionarySelection: async (mediaId) => {
|
setCharacterDictionarySelection: async (mediaId) => {
|
||||||
calls.push(mediaId);
|
calls.push(mediaId);
|
||||||
return {
|
return {
|
||||||
@@ -1223,7 +1227,7 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
|
|||||||
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
|
const getHandler = handlers.handle.get(IPC_CHANNELS.request.getCharacterDictionarySelection);
|
||||||
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
|
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setCharacterDictionarySelection);
|
||||||
|
|
||||||
assert.deepEqual(await getHandler!({}), {
|
assert.deepEqual(await getHandler!({}, ' Re:ZERO '), {
|
||||||
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
guessTitle: 'Re ZERO, Starting Life in Another World',
|
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||||
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
current: { id: 10607, title: 'Rerere no Tensai Bakabon', episodes: 24 },
|
||||||
@@ -1241,4 +1245,5 @@ test('registerIpcHandlers exposes character dictionary selection handlers', asyn
|
|||||||
staleMediaIds: [10607],
|
staleMediaIds: [10607],
|
||||||
});
|
});
|
||||||
assert.deepEqual(calls, [21355]);
|
assert.deepEqual(calls, [21355]);
|
||||||
|
assert.deepEqual(searches, ['Re:ZERO']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export interface IpcServiceDeps {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
@@ -223,7 +223,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
getCharacterDictionarySelection?: (searchTitle?: string) => Promise<unknown>;
|
||||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
|
||||||
@@ -615,8 +615,9 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return await deps.retryAnilistQueueNow();
|
return await deps.retryAnilistQueueNow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async () => {
|
ipc.handle(IPC_CHANNELS.request.getCharacterDictionarySelection, async (_event, searchTitle) => {
|
||||||
return await (deps.getCharacterDictionarySelection?.() ??
|
const normalizedSearchTitle = typeof searchTitle === 'string' ? searchTitle.trim() : undefined;
|
||||||
|
return await (deps.getCharacterDictionarySelection?.(normalizedSearchTitle) ??
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
seriesKey: '',
|
seriesKey: '',
|
||||||
guessTitle: null,
|
guessTitle: null,
|
||||||
|
|||||||
@@ -149,6 +149,70 @@ test('tokenizeSubtitle preserves Yomitan name-match metadata on tokens', async (
|
|||||||
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
|
assert.equal((result.tokens?.[1] as { isNameMatch?: boolean } | undefined)?.isNameMatch, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle attaches character image metadata to name matches when enabled', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'アクアです',
|
||||||
|
makeDepsFromYomitanTokens(
|
||||||
|
[
|
||||||
|
{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true },
|
||||||
|
{ surface: 'です', reading: 'です', headword: 'です' },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
getNameMatchImagesEnabled: () => true,
|
||||||
|
getCharacterNameImage: (term) =>
|
||||||
|
term === 'アクア'
|
||||||
|
? {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アクア',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
} as Partial<TokenizerServiceDeps>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result.tokens?.[0]?.characterImage, {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アクア',
|
||||||
|
});
|
||||||
|
assert.equal(result.tokens?.[1]?.characterImage, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle keeps tokens when character image lookup throws', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'アクア',
|
||||||
|
makeDepsFromYomitanTokens(
|
||||||
|
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
|
||||||
|
{
|
||||||
|
getNameMatchImagesEnabled: () => true,
|
||||||
|
getCharacterNameImage: () => {
|
||||||
|
throw new Error('image lookup failed');
|
||||||
|
},
|
||||||
|
} as Partial<TokenizerServiceDeps>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.tokens?.[0]?.surface, 'アクア');
|
||||||
|
assert.equal(result.tokens?.[0]?.characterImage, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tokenizeSubtitle omits character image metadata when name-match images are disabled', async () => {
|
||||||
|
const result = await tokenizeSubtitle(
|
||||||
|
'アクア',
|
||||||
|
makeDepsFromYomitanTokens(
|
||||||
|
[{ surface: 'アクア', reading: 'あくあ', headword: 'アクア', isNameMatch: true }],
|
||||||
|
{
|
||||||
|
getNameMatchImagesEnabled: () => false,
|
||||||
|
getCharacterNameImage: () => ({
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アクア',
|
||||||
|
}),
|
||||||
|
} as Partial<TokenizerServiceDeps>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.tokens?.[0]?.characterImage, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
|
test('tokenizeSubtitle caches JLPT lookups across repeated tokens', async () => {
|
||||||
let lookupCalls = 0;
|
let lookupCalls = 0;
|
||||||
const result = await tokenizeSubtitle(
|
const result = await tokenizeSubtitle(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mergeTokens } from '../../token-merger';
|
|||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import {
|
import {
|
||||||
FrequencyDictionaryMatchMode,
|
FrequencyDictionaryMatchMode,
|
||||||
|
CharacterNameImage,
|
||||||
MergedToken,
|
MergedToken,
|
||||||
NPlusOneMatchMode,
|
NPlusOneMatchMode,
|
||||||
SubtitleData,
|
SubtitleData,
|
||||||
@@ -48,6 +49,8 @@ export interface TokenizerServiceDeps {
|
|||||||
getNPlusOneEnabled?: () => boolean;
|
getNPlusOneEnabled?: () => boolean;
|
||||||
getJlptEnabled?: () => boolean;
|
getJlptEnabled?: () => boolean;
|
||||||
getNameMatchEnabled?: () => boolean;
|
getNameMatchEnabled?: () => boolean;
|
||||||
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -80,6 +83,8 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
getNPlusOneEnabled?: () => boolean;
|
getNPlusOneEnabled?: () => boolean;
|
||||||
getJlptEnabled?: () => boolean;
|
getJlptEnabled?: () => boolean;
|
||||||
getNameMatchEnabled?: () => boolean;
|
getNameMatchEnabled?: () => boolean;
|
||||||
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
|
getCharacterNameImage?: (term: string) => CharacterNameImage | null;
|
||||||
getFrequencyDictionaryEnabled?: () => boolean;
|
getFrequencyDictionaryEnabled?: () => boolean;
|
||||||
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
getFrequencyDictionaryMatchMode?: () => FrequencyDictionaryMatchMode;
|
||||||
getFrequencyRank?: FrequencyDictionaryLookup;
|
getFrequencyRank?: FrequencyDictionaryLookup;
|
||||||
@@ -94,6 +99,7 @@ interface TokenizerAnnotationOptions {
|
|||||||
nPlusOneEnabled: boolean;
|
nPlusOneEnabled: boolean;
|
||||||
jlptEnabled: boolean;
|
jlptEnabled: boolean;
|
||||||
nameMatchEnabled: boolean;
|
nameMatchEnabled: boolean;
|
||||||
|
nameMatchImagesEnabled: boolean;
|
||||||
frequencyEnabled: boolean;
|
frequencyEnabled: boolean;
|
||||||
frequencyMatchMode: FrequencyDictionaryMatchMode;
|
frequencyMatchMode: FrequencyDictionaryMatchMode;
|
||||||
minSentenceWordsForNPlusOne: number | undefined;
|
minSentenceWordsForNPlusOne: number | undefined;
|
||||||
@@ -229,6 +235,8 @@ export function createTokenizerDepsRuntime(
|
|||||||
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
getNPlusOneEnabled: options.getNPlusOneEnabled,
|
||||||
getJlptEnabled: options.getJlptEnabled,
|
getJlptEnabled: options.getJlptEnabled,
|
||||||
getNameMatchEnabled: options.getNameMatchEnabled,
|
getNameMatchEnabled: options.getNameMatchEnabled,
|
||||||
|
getNameMatchImagesEnabled: options.getNameMatchImagesEnabled,
|
||||||
|
getCharacterNameImage: options.getCharacterNameImage,
|
||||||
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
getFrequencyDictionaryEnabled: options.getFrequencyDictionaryEnabled,
|
||||||
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
getFrequencyDictionaryMatchMode: options.getFrequencyDictionaryMatchMode ?? (() => 'headword'),
|
||||||
getFrequencyRank: options.getFrequencyRank,
|
getFrequencyRank: options.getFrequencyRank,
|
||||||
@@ -684,6 +692,7 @@ function getAnnotationOptions(deps: TokenizerServiceDeps): TokenizerAnnotationOp
|
|||||||
nPlusOneEnabled,
|
nPlusOneEnabled,
|
||||||
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
jlptEnabled: deps.getJlptEnabled?.() !== false,
|
||||||
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
nameMatchEnabled: deps.getNameMatchEnabled?.() !== false,
|
||||||
|
nameMatchImagesEnabled: deps.getNameMatchImagesEnabled?.() === true,
|
||||||
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
frequencyEnabled: deps.getFrequencyDictionaryEnabled?.() !== false,
|
||||||
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
|
frequencyMatchMode: deps.getFrequencyDictionaryMatchMode?.() ?? 'headword',
|
||||||
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
minSentenceWordsForNPlusOne: deps.getMinSentenceWordsForNPlusOne?.(),
|
||||||
@@ -780,6 +789,53 @@ async function parseWithYomitanInternalParser(
|
|||||||
return enrichedTokens;
|
return enrichedTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCharacterNameImageForToken(
|
||||||
|
token: MergedToken,
|
||||||
|
getCharacterNameImage: (term: string) => CharacterNameImage | null,
|
||||||
|
): CharacterNameImage | null {
|
||||||
|
const terms = [token.headword, token.surface]
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter((term, index, list) => term.length > 0 && list.indexOf(term) === index);
|
||||||
|
for (const term of terms) {
|
||||||
|
const image = getCharacterNameImage(term);
|
||||||
|
if (image) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCharacterNameImages(
|
||||||
|
tokens: MergedToken[],
|
||||||
|
deps: TokenizerServiceDeps,
|
||||||
|
options: TokenizerAnnotationOptions,
|
||||||
|
): MergedToken[] {
|
||||||
|
if (
|
||||||
|
!options.nameMatchEnabled ||
|
||||||
|
!options.nameMatchImagesEnabled ||
|
||||||
|
typeof deps.getCharacterNameImage !== 'function'
|
||||||
|
) {
|
||||||
|
return tokens.map((token) => ({ ...token, characterImage: undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCharacterNameImage = deps.getCharacterNameImage;
|
||||||
|
return tokens.map((token) => {
|
||||||
|
if (token.isNameMatch !== true) {
|
||||||
|
return { ...token, characterImage: undefined };
|
||||||
|
}
|
||||||
|
let characterImage: CharacterNameImage | undefined;
|
||||||
|
try {
|
||||||
|
characterImage = resolveCharacterNameImageForToken(token, getCharacterNameImage) ?? undefined;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to resolve character name image:', (err as Error).message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...token,
|
||||||
|
characterImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function tokenizeSubtitle(
|
export async function tokenizeSubtitle(
|
||||||
text: string,
|
text: string,
|
||||||
deps: TokenizerServiceDeps,
|
deps: TokenizerServiceDeps,
|
||||||
@@ -805,9 +861,10 @@ export async function tokenizeSubtitle(
|
|||||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||||
|
const renderedTokens = applyCharacterNameImages(annotatedTokens, deps, annotationOptions);
|
||||||
return {
|
return {
|
||||||
text: displayText,
|
text: displayText,
|
||||||
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
|
tokens: renderedTokens.length > 0 ? renderedTokens : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,30 @@ test('stripSubtitleAnnotationMetadata keeps known hover data while clearing non-
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stripSubtitleAnnotationMetadata clears character image metadata from excluded name matches', () => {
|
||||||
|
const token = makeToken({
|
||||||
|
surface: 'は',
|
||||||
|
headword: 'は',
|
||||||
|
reading: 'ハ',
|
||||||
|
partOfSpeech: PartOfSpeech.particle,
|
||||||
|
pos1: '助詞',
|
||||||
|
isNameMatch: true,
|
||||||
|
});
|
||||||
|
token.characterImage = {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'は',
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(stripSubtitleAnnotationMetadata(token), {
|
||||||
|
...token,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
isNameMatch: false,
|
||||||
|
characterImage: undefined,
|
||||||
|
jlptLevel: undefined,
|
||||||
|
frequencyRank: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => {
|
test('stripSubtitleAnnotationMetadata leaves content tokens unchanged', () => {
|
||||||
const token = makeToken({
|
const token = makeToken({
|
||||||
surface: '猫',
|
surface: '猫',
|
||||||
|
|||||||
@@ -508,11 +508,17 @@ export function stripSubtitleAnnotationMetadata(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const strippedToken = {
|
||||||
...token,
|
...token,
|
||||||
isNPlusOneTarget: false,
|
isNPlusOneTarget: false,
|
||||||
isNameMatch: false,
|
isNameMatch: false,
|
||||||
jlptLevel: undefined,
|
jlptLevel: undefined,
|
||||||
frequencyRank: undefined,
|
frequencyRank: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if ('characterImage' in strippedToken) {
|
||||||
|
strippedToken.characterImage = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strippedToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1577,18 +1577,24 @@ test('dictionary settings helpers upsert and remove dictionary entries without r
|
|||||||
assert.match(upsertScript ?? '', /"enabled":true/);
|
assert.match(upsertScript ?? '', /"enabled":true/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
|
test('importYomitanDictionaryFromZip imports via localhost URL instead of embedding archive bytes in script', async () => {
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||||
const zipPath = path.join(tempDir, 'dict.zip');
|
const zipPath = path.join(tempDir, 'dict.zip');
|
||||||
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||||
|
|
||||||
const scripts: string[] = [];
|
const scripts: string[] = [];
|
||||||
|
const servedArchives: string[] = [];
|
||||||
const settingsWindow = {
|
const settingsWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
destroy: () => undefined,
|
destroy: () => undefined,
|
||||||
webContents: {
|
webContents: {
|
||||||
executeJavaScript: async (script: string) => {
|
executeJavaScript: async (script: string) => {
|
||||||
scripts.push(script);
|
scripts.push(script);
|
||||||
|
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
|
||||||
|
if (urlMatch) {
|
||||||
|
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
|
||||||
|
servedArchives.push(await response.text());
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1611,15 +1617,103 @@ test('importYomitanDictionaryFromZip uses settings automation bridge instead of
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
|
scripts.some((script) => script.includes('importDictionaryArchiveUrl')),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(servedArchives, ['zip-bytes']);
|
||||||
|
assert.equal(
|
||||||
|
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
scripts.some((script) => script.includes('subminerImportDictionary')),
|
scripts.some((script) => script.includes('subminerImportDictionary')),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('importYomitanDictionaryFromZip falls back to base64 import for older Yomitan bridge', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||||
|
const zipPath = path.join(tempDir, 'dict.zip');
|
||||||
|
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||||
|
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const settingsWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => undefined,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
scripts.push(script);
|
||||||
|
if (
|
||||||
|
script.includes(
|
||||||
|
'typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createDeps(async () => true, {
|
||||||
|
createYomitanExtensionWindow: async (pageName: string) => {
|
||||||
|
assert.equal(pageName, 'settings.html');
|
||||||
|
return settingsWindow;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(imported, true);
|
||||||
|
assert.equal(
|
||||||
|
scripts.some((script) => script.includes('importDictionaryArchiveBase64')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
scripts.some((script) => script.includes('importDictionaryArchiveUrl(')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
scripts.some((script) => script.includes('emlwLWJ5dGVz')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importYomitanDictionaryFromZip returns false when served archive cannot be read', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||||
|
const zipPath = path.join(tempDir, 'dict.zip');
|
||||||
|
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||||
|
|
||||||
|
const settingsWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => undefined,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
const urlMatch = script.match(/importDictionaryArchiveUrl\(\s*"([^"]+)"/);
|
||||||
|
if (!urlMatch) return true;
|
||||||
|
fs.unlinkSync(zipPath);
|
||||||
|
const response = await fetch(JSON.parse(`"${urlMatch[1]}"`) as string);
|
||||||
|
return response.ok;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createDeps(async () => true, {
|
||||||
|
createYomitanExtensionWindow: async (pageName: string) => {
|
||||||
|
assert.equal(pageName, 'settings.html');
|
||||||
|
return settingsWindow;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(imported, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
|
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
|
||||||
const scripts: string[] = [];
|
const scripts: string[] = [];
|
||||||
const settingsWindow = {
|
const settingsWindow = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as http from 'http';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { selectYomitanParseTokens } from './parser-selection-stage';
|
import { selectYomitanParseTokens } from './parser-selection-stage';
|
||||||
|
|
||||||
@@ -705,6 +706,70 @@ async function invokeYomitanSettingsAutomation<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function serveDictionaryZipOnce<T>(
|
||||||
|
zipPath: string,
|
||||||
|
callback: (url: string) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const fileName = path.basename(zipPath);
|
||||||
|
const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const requestPath = `/${token}/${encodeURIComponent(fileName)}`;
|
||||||
|
let served = false;
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
response.writeHead(204, {
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'GET, OPTIONS',
|
||||||
|
});
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method !== 'GET' || request.url !== requestPath || served) {
|
||||||
|
response.writeHead(404, { 'access-control-allow-origin': '*' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
served = true;
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(zipPath).size;
|
||||||
|
} catch {
|
||||||
|
response.writeHead(500, { 'access-control-allow-origin': '*' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, {
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'content-length': String(size),
|
||||||
|
'content-type': 'application/zip',
|
||||||
|
});
|
||||||
|
const stream = fs.createReadStream(zipPath);
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!response.headersSent) {
|
||||||
|
response.writeHead(500, { 'access-control-allow-origin': '*' });
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.destroy();
|
||||||
|
});
|
||||||
|
stream.pipe(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
throw new Error('Dictionary import server did not bind to a TCP port.');
|
||||||
|
}
|
||||||
|
return await callback(`http://127.0.0.1:${address.port}${requestPath}`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const YOMITAN_SCANNING_HELPERS = String.raw`
|
const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||||
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096];
|
||||||
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6];
|
||||||
@@ -1863,17 +1928,43 @@ export async function importYomitanDictionaryFromZip(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
|
const supportsUrlImport = await invokeYomitanSettingsAutomation<boolean>(
|
||||||
const script = `
|
`
|
||||||
|
(() => typeof globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl === "function")();
|
||||||
|
`,
|
||||||
|
deps,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result =
|
||||||
|
supportsUrlImport === true
|
||||||
|
? await serveDictionaryZipOnce(normalizedZipPath, async (archiveUrl) =>
|
||||||
|
invokeYomitanSettingsAutomation<boolean>(
|
||||||
|
`
|
||||||
|
(async () => {
|
||||||
|
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveUrl(
|
||||||
|
${JSON.stringify(archiveUrl)}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
deps,
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: await invokeYomitanSettingsAutomation<boolean>(
|
||||||
|
`
|
||||||
(async () => {
|
(async () => {
|
||||||
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
||||||
${JSON.stringify(archiveBase64)},
|
${JSON.stringify(fs.readFileSync(normalizedZipPath).toString('base64'))},
|
||||||
${JSON.stringify(path.basename(normalizedZipPath))}
|
${JSON.stringify(path.basename(normalizedZipPath))}
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
})();
|
})();
|
||||||
`;
|
`,
|
||||||
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
|
deps,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
return result === true;
|
return result === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -518,6 +518,7 @@ import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility
|
|||||||
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
|
import { createStatsOverlayVisibilityChangeHandler } from './main/runtime/stats-overlay-visibility';
|
||||||
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 { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
import { createCharacterDictionaryAutoSyncRuntimeService } 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';
|
||||||
@@ -2178,6 +2179,7 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
|||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||||
|
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
getCollapsibleSectionOpenState: (section) =>
|
getCollapsibleSectionOpenState: (section) =>
|
||||||
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
@@ -2185,6 +2187,10 @@ 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: () => {
|
||||||
@@ -4728,6 +4734,8 @@ const {
|
|||||||
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||||
!isYoutubePlaybackActiveNow(),
|
!isYoutubePlaybackActiveNow(),
|
||||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||||
|
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
getCharacterNameImage: (term) => characterDictionaryImageLookup.get(term),
|
||||||
getFrequencyDictionaryEnabled: () =>
|
getFrequencyDictionaryEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -5967,8 +5975,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||||
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
||||||
getCharacterDictionarySelection: () =>
|
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||||
characterDictionaryRuntime.getManualSelectionSnapshot(),
|
characterDictionaryRuntime.getManualSelectionSnapshot(undefined, searchTitle),
|
||||||
setCharacterDictionarySelection: async (mediaId: number) =>
|
setCharacterDictionarySelection: async (mediaId: number) =>
|
||||||
applyCharacterDictionarySelection(
|
applyCharacterDictionarySelection(
|
||||||
{ mediaId },
|
{ mediaId },
|
||||||
|
|||||||
@@ -195,22 +195,45 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
|
|||||||
assert.equal(nameDiv.tag, 'div');
|
assert.equal(nameDiv.tag, 'div');
|
||||||
assert.equal(nameDiv.content, 'アレクシア・ミドガル');
|
assert.equal(nameDiv.content, 'アレクシア・ミドガル');
|
||||||
|
|
||||||
const secondaryNameDiv = children[1] as { tag: string; content: string };
|
assert.equal(
|
||||||
assert.equal(secondaryNameDiv.tag, 'div');
|
children.some((child) => (child as { content?: unknown }).content === 'Alexia Midgar'),
|
||||||
assert.equal(secondaryNameDiv.content, 'Alexia Midgar');
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const imageWrap = children[2] as { tag: string; content: Record<string, unknown> };
|
const imageWrap = children.find((child) => {
|
||||||
|
const content = (child as { content?: unknown }).content;
|
||||||
|
return (
|
||||||
|
content &&
|
||||||
|
typeof content === 'object' &&
|
||||||
|
!Array.isArray(content) &&
|
||||||
|
(content as { path?: unknown }).path === 'img/m130298-c123.png'
|
||||||
|
);
|
||||||
|
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||||
|
assert.ok(imageWrap);
|
||||||
assert.equal(imageWrap.tag, 'div');
|
assert.equal(imageWrap.tag, 'div');
|
||||||
const image = imageWrap.content as Record<string, unknown>;
|
const image = imageWrap.content as Record<string, unknown>;
|
||||||
assert.equal(image.tag, 'img');
|
assert.equal(image.tag, 'img');
|
||||||
assert.equal(image.path, 'img/m130298-c123.png');
|
assert.equal(image.path, 'img/m130298-c123.png');
|
||||||
assert.equal(image.sizeUnits, 'em');
|
assert.equal(image.sizeUnits, 'em');
|
||||||
|
|
||||||
const sourceDiv = children[3] as { tag: string; content: string };
|
const sourceDiv = children.find((child) => {
|
||||||
|
const content = (child as { content?: unknown }).content;
|
||||||
|
return typeof content === 'string' && content.includes('The Eminence in Shadow');
|
||||||
|
}) as { tag: string; content: string } | undefined;
|
||||||
|
assert.ok(sourceDiv);
|
||||||
assert.equal(sourceDiv.tag, 'div');
|
assert.equal(sourceDiv.tag, 'div');
|
||||||
assert.ok(sourceDiv.content.includes('The Eminence in Shadow'));
|
assert.ok(sourceDiv.content.includes('The Eminence in Shadow'));
|
||||||
|
|
||||||
const roleBadgeDiv = children[4] as { tag: string; content: Record<string, unknown> };
|
const roleBadgeDiv = children.find((child) => {
|
||||||
|
const content = (child as { content?: unknown }).content;
|
||||||
|
return (
|
||||||
|
content &&
|
||||||
|
typeof content === 'object' &&
|
||||||
|
!Array.isArray(content) &&
|
||||||
|
(content as { content?: unknown }).content === 'Main Character'
|
||||||
|
);
|
||||||
|
}) as { tag: string; content: Record<string, unknown> } | undefined;
|
||||||
|
assert.ok(roleBadgeDiv);
|
||||||
assert.equal(roleBadgeDiv.tag, 'div');
|
assert.equal(roleBadgeDiv.tag, 'div');
|
||||||
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
||||||
assert.equal(badge.tag, 'span');
|
assert.equal(badge.tag, 'span');
|
||||||
@@ -1882,9 +1905,9 @@ test('generateForCurrentMedia logs progress while resolving and rebuilding snaps
|
|||||||
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
'[dictionary] snapshot miss for AniList 130298, fetching characters',
|
||||||
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
'[dictionary] downloaded AniList character page 1 for AniList 130298',
|
||||||
'[dictionary] downloading 1 images for AniList 130298',
|
'[dictionary] downloading 1 images for AniList 130298',
|
||||||
'[dictionary] stored snapshot for AniList 130298: 32 terms',
|
'[dictionary] stored snapshot for AniList 130298: 16 terms',
|
||||||
'[dictionary] building ZIP for AniList 130298',
|
'[dictionary] building ZIP for AniList 130298',
|
||||||
'[dictionary] generated AniList 130298: 32 terms -> ' +
|
'[dictionary] generated AniList 130298: 16 terms -> ' +
|
||||||
path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'),
|
path.join(userDataPath, 'character-dictionaries', 'anilist-130298.zip'),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
buildCharacterDictionarySeriesKey,
|
buildCharacterDictionarySeriesKey,
|
||||||
createCharacterDictionaryManualSelectionStore,
|
createCharacterDictionaryManualSelectionStore,
|
||||||
} from './character-dictionary-runtime/manual-selection';
|
} from './character-dictionary-runtime/manual-selection';
|
||||||
|
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||||
import type {
|
import type {
|
||||||
AniListMediaCandidate,
|
AniListMediaCandidate,
|
||||||
CharacterDictionaryBuildResult,
|
CharacterDictionaryBuildResult,
|
||||||
@@ -151,6 +152,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||||
getManualSelectionSnapshot: (
|
getManualSelectionSnapshot: (
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
|
searchTitle?: string,
|
||||||
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
) => Promise<CharacterDictionaryManualSelectionSnapshot>;
|
||||||
setManualSelection: (request: {
|
setManualSelection: (request: {
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
@@ -168,6 +170,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
userDataPath: deps.userDataPath,
|
userDataPath: deps.userDataPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => {
|
||||||
|
if (deps.getNameMatchImagesEnabled?.() !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !snapshotHasCharacterNameImages(snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||||
let hasAniListRequest = false;
|
let hasAniListRequest = false;
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
@@ -205,12 +214,19 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
mediaTitle: guessInput.mediaTitle,
|
mediaTitle: guessInput.mediaTitle,
|
||||||
guess: guessed,
|
guess: guessed,
|
||||||
}),
|
}),
|
||||||
|
unscopedSeriesKey: buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: null,
|
||||||
|
mediaTitle: guessInput.mediaTitle,
|
||||||
|
guess: guessed,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const findCachedSnapshotForSeriesKey = (
|
const findCachedSnapshotForSeriesKey = (
|
||||||
seriesKey: string,
|
seriesKey: string,
|
||||||
|
fallbackSeriesKey?: string,
|
||||||
): CharacterDictionarySnapshot | null => {
|
): CharacterDictionarySnapshot | null => {
|
||||||
|
const acceptedKeys = new Set([seriesKey, fallbackSeriesKey].filter(Boolean));
|
||||||
return (
|
return (
|
||||||
readCachedSnapshots(outputDir).find((snapshot) => {
|
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||||
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||||
@@ -223,7 +239,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return snapshotSeriesKey === seriesKey;
|
return acceptedKeys.has(snapshotSeriesKey);
|
||||||
}) ?? null
|
}) ?? null
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -233,7 +249,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
beforeRequest?: () => Promise<void>,
|
beforeRequest?: () => Promise<void>,
|
||||||
): Promise<ResolvedAniListMedia> => {
|
): Promise<ResolvedAniListMedia> => {
|
||||||
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
deps.logInfo?.('[dictionary] resolving current anime for character dictionary generation');
|
||||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
const { guessed, seriesKey, unscopedSeriesKey } = await guessCurrentMedia(targetPath);
|
||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
`[dictionary] current anime guess: ${guessed.title.trim()}${
|
||||||
typeof guessed.episode === 'number' && guessed.episode > 0
|
typeof guessed.episode === 'number' && guessed.episode > 0
|
||||||
@@ -267,7 +283,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey, unscopedSeriesKey);
|
||||||
if (cachedSnapshot) {
|
if (cachedSnapshot) {
|
||||||
writeCachedMediaResolution(outputDir, {
|
writeCachedMediaResolution(outputDir, {
|
||||||
seriesKey,
|
seriesKey,
|
||||||
@@ -301,7 +317,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
): Promise<CharacterDictionarySnapshotResult> => {
|
): Promise<CharacterDictionarySnapshotResult> => {
|
||||||
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
||||||
const cachedSnapshot = readSnapshot(snapshotPath);
|
const cachedSnapshot = readSnapshot(snapshotPath);
|
||||||
if (cachedSnapshot) {
|
if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) {
|
||||||
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
|
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
|
||||||
return {
|
return {
|
||||||
mediaId: cachedSnapshot.mediaId,
|
mediaId: cachedSnapshot.mediaId,
|
||||||
@@ -311,6 +327,11 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
updatedAt: cachedSnapshot.updatedAt,
|
updatedAt: cachedSnapshot.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
progress?.onGenerating?.({
|
progress?.onGenerating?.({
|
||||||
mediaId,
|
mediaId,
|
||||||
@@ -455,28 +476,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
entryCount,
|
entryCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getManualSelectionSnapshot: async (targetPath?: string) => {
|
getManualSelectionSnapshot: async (targetPath?: string, searchTitle?: string) => {
|
||||||
const waitForAniListRequestSlot = createAniListRequestSlot();
|
const waitForAniListRequestSlot = createAniListRequestSlot();
|
||||||
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
const { guessed, seriesKey } = await guessCurrentMedia(targetPath);
|
||||||
const [candidates, override] = await Promise.all([
|
const normalizedSearchTitle = searchTitle?.trim();
|
||||||
searchAniListMediaCandidates(guessed.title, waitForAniListRequestSlot),
|
const shouldUseExplicitSearch = searchTitle !== undefined;
|
||||||
|
const candidateSearchTitle = shouldUseExplicitSearch ? normalizedSearchTitle : guessed.title;
|
||||||
|
const candidates = candidateSearchTitle
|
||||||
|
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
||||||
|
: [];
|
||||||
|
const [override, current] = await Promise.all([
|
||||||
manualSelectionStore.getOverride(seriesKey),
|
manualSelectionStore.getOverride(seriesKey),
|
||||||
]);
|
shouldUseExplicitSearch
|
||||||
const current = await resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
? Promise.resolve(null)
|
||||||
|
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||||
.then(
|
.then(
|
||||||
(entry): AniListMediaCandidate => ({
|
(entry): AniListMediaCandidate => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
title: entry.title,
|
title: entry.title,
|
||||||
episodes: candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
episodes:
|
||||||
|
candidates.find((candidate) => candidate.id === entry.id)?.episodes ?? null,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.catch(() => null);
|
.catch(() => null),
|
||||||
|
]);
|
||||||
|
const overrideCandidate = override
|
||||||
|
? candidates.find((candidate) => candidate.id === override.mediaId)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
seriesKey,
|
seriesKey,
|
||||||
guessTitle: guessed.title,
|
guessTitle: guessed.title,
|
||||||
current,
|
current,
|
||||||
override: override
|
override: override
|
||||||
? { id: override.mediaId, title: override.mediaTitle, episodes: null }
|
? {
|
||||||
|
id: override.mediaId,
|
||||||
|
title: override.mediaTitle,
|
||||||
|
episodes: overrideCandidate?.episodes ?? null,
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
candidates,
|
candidates,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { applyCollapsibleOpenStatesToTermEntries } from './build';
|
import { applyCollapsibleOpenStatesToTermEntries, buildSnapshotFromCharacters } from './build';
|
||||||
import type { CharacterDictionaryTermEntry } from './types';
|
import type { CharacterDictionaryTermEntry, CharacterRecord } from './types';
|
||||||
|
|
||||||
test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open states', () => {
|
test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open states', () => {
|
||||||
const termEntries: CharacterDictionaryTermEntry[] = [
|
const termEntries: CharacterDictionaryTermEntry[] = [
|
||||||
@@ -56,3 +56,66 @@ test('applyCollapsibleOpenStatesToTermEntries reapplies configured details open
|
|||||||
assert.equal(glossaryEntry.content.content[0]?.open, true);
|
assert.equal(glossaryEntry.content.content[0]?.open, true);
|
||||||
assert.equal(glossaryEntry.content.content[1]?.open, false);
|
assert.equal(glossaryEntry.content.content[1]?.open, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSnapshotFromCharacters shows Japanese aliases without adding romanized names as lookup entries', () => {
|
||||||
|
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(
|
||||||
|
100,
|
||||||
|
'KonoSuba',
|
||||||
|
[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: {
|
||||||
|
content: Array<{ content?: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const wholeGlossary = JSON.stringify(glossaryEntry);
|
||||||
|
|
||||||
|
const knownNames = glossaryEntry.content.content.find((node) => {
|
||||||
|
const content = node.content;
|
||||||
|
return (
|
||||||
|
Array.isArray(content) &&
|
||||||
|
content.some(
|
||||||
|
(child) =>
|
||||||
|
child &&
|
||||||
|
typeof child === 'object' &&
|
||||||
|
(child as { content?: unknown }).content === 'Known names',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}) as { content: Array<{ content?: unknown }> } | undefined;
|
||||||
|
assert.ok(knownNames, 'expected a Known names block in the character glossary');
|
||||||
|
const knownNameItems = JSON.stringify(knownNames.content);
|
||||||
|
const terms = snapshot.termEntries.map(([term]) => term);
|
||||||
|
|
||||||
|
assert.match(knownNameItems, /アクア/);
|
||||||
|
assert.match(knownNameItems, /阿久亜/);
|
||||||
|
assert.doesNotMatch(wholeGlossary, /Aqua/);
|
||||||
|
assert.doesNotMatch(knownNameItems, /Aqua/);
|
||||||
|
assert.doesNotMatch(knownNameItems, /アクア様/);
|
||||||
|
assert.equal(terms.includes('Aqua'), false);
|
||||||
|
assert.equal(terms.includes('アクア'), true);
|
||||||
|
assert.equal(terms.includes('阿久亜'), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -52,3 +52,18 @@ test('readSnapshot ignores snapshots written with an older format version', () =
|
|||||||
|
|
||||||
assert.equal(readSnapshot(snapshotPath), null);
|
assert.equal(readSnapshot(snapshotPath), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('readSnapshot ignores v15 snapshots with stale romanized character-name entries', () => {
|
||||||
|
const outputDir = makeTempDir();
|
||||||
|
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||||
|
const staleSnapshot = {
|
||||||
|
...createSnapshot(),
|
||||||
|
formatVersion: 15,
|
||||||
|
termEntries: [['Vanir', 'ばにる', 'name primary', '', 75, ['Vanir'], 0, '']],
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
||||||
|
fs.writeFileSync(snapshotPath, JSON.stringify(staleSnapshot), 'utf8');
|
||||||
|
|
||||||
|
assert.equal(readSnapshot(snapshotPath), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = 15;
|
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 16;
|
||||||
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 = [
|
||||||
|
|||||||
@@ -191,11 +191,51 @@ function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
|||||||
return 'side';
|
return 'side';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferImageExt(contentType: string | null): string {
|
function inferImageExtFromBytes(bytes: Buffer): string | null {
|
||||||
|
if (
|
||||||
|
bytes.length >= 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47
|
||||||
|
) {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'webp';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF89a') {
|
||||||
|
return 'gif';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 6 && bytes.subarray(0, 6).toString('ascii') === 'GIF87a') {
|
||||||
|
return 'gif';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||||
|
bytes.subarray(8, 12).toString('ascii') === 'avif'
|
||||||
|
) {
|
||||||
|
return 'avif';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageExt(contentType: string | null, bytes: Buffer): string {
|
||||||
|
const extFromBytes = inferImageExtFromBytes(bytes);
|
||||||
|
if (extFromBytes) return extFromBytes;
|
||||||
|
|
||||||
const normalized = (contentType || '').toLowerCase();
|
const normalized = (contentType || '').toLowerCase();
|
||||||
if (normalized.includes('png')) return 'png';
|
if (normalized.includes('png')) return 'png';
|
||||||
if (normalized.includes('gif')) return 'gif';
|
if (normalized.includes('gif')) return 'gif';
|
||||||
if (normalized.includes('webp')) return 'webp';
|
if (normalized.includes('webp')) return 'webp';
|
||||||
|
if (normalized.includes('avif')) return 'avif';
|
||||||
return 'jpg';
|
return 'jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +502,7 @@ export async function downloadCharacterImage(
|
|||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const bytes = Buffer.from(await response.arrayBuffer());
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
if (bytes.length === 0) return null;
|
if (bytes.length === 0) return null;
|
||||||
const ext = inferImageExt(response.headers.get('content-type'));
|
const ext = inferImageExt(response.headers.get('content-type'), bytes);
|
||||||
return {
|
return {
|
||||||
filename: `c${charId}.${ext}`,
|
filename: `c${charId}.${ext}`,
|
||||||
ext,
|
ext,
|
||||||
|
|||||||
@@ -117,20 +117,44 @@ function buildVoicedByContent(
|
|||||||
return { tag: 'ul', style: { marginTop: '0.15em' }, content: items };
|
return { tag: 'ul', style: { marginTop: '0.15em' }, content: items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildKnownNamesBlock(nameTerms: string[]): Record<string, unknown> | null {
|
||||||
|
const visibleTerms = [...new Set(nameTerms.map((term) => term.trim()).filter(Boolean))];
|
||||||
|
if (visibleTerms.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: 'div',
|
||||||
|
style: { fontSize: '0.85em', marginBottom: '0.25em' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
style: { fontWeight: 'bold', color: '#d0d0d0', marginBottom: '0.1em' },
|
||||||
|
content: 'Known names',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'ul',
|
||||||
|
style: { marginTop: '0', marginBottom: '0', paddingLeft: '1.2em' },
|
||||||
|
content: visibleTerms.map((term) => ({
|
||||||
|
tag: 'li',
|
||||||
|
content: term,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createDefinitionGlossary(
|
export function createDefinitionGlossary(
|
||||||
character: CharacterRecord,
|
character: CharacterRecord,
|
||||||
mediaTitle: string,
|
mediaTitle: string,
|
||||||
imagePath: string | null,
|
imagePath: string | null,
|
||||||
vaImagePaths: Map<number, string>,
|
vaImagePaths: Map<number, string>,
|
||||||
|
nameTerms: string[],
|
||||||
getCollapsibleSectionOpenState: (
|
getCollapsibleSectionOpenState: (
|
||||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||||
) => boolean,
|
) => boolean,
|
||||||
): CharacterDictionaryGlossaryEntry[] {
|
): CharacterDictionaryGlossaryEntry[] {
|
||||||
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
|
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
|
||||||
const secondaryName =
|
|
||||||
character.nativeName && character.fullName && character.fullName !== character.nativeName
|
|
||||||
? character.fullName
|
|
||||||
: null;
|
|
||||||
const { fields, text: descriptionText } = parseCharacterDescription(character.description);
|
const { fields, text: descriptionText } = parseCharacterDescription(character.description);
|
||||||
|
|
||||||
const content: Array<string | Record<string, unknown>> = [
|
const content: Array<string | Record<string, unknown>> = [
|
||||||
@@ -141,12 +165,9 @@ export function createDefinitionGlossary(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (secondaryName) {
|
const knownNamesBlock = buildKnownNamesBlock(nameTerms);
|
||||||
content.push({
|
if (knownNamesBlock) {
|
||||||
tag: 'div',
|
content.push(knownNamesBlock);
|
||||||
style: { fontSize: '0.85em', fontStyle: 'italic', color: '#b0b0b0', marginBottom: '0.2em' },
|
|
||||||
content: secondaryName,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imagePath) {
|
if (imagePath) {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
|
import { buildCharacterNameImageIndexFromSnapshots } from './image-lookup';
|
||||||
|
import type { CharacterDictionarySnapshot } from './types';
|
||||||
|
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=';
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-image-lookup-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildCharacterNameImageIndexFromSnapshots maps name terms to character portrait data URLs', () => {
|
||||||
|
const outputDir = makeTempDir();
|
||||||
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 1,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
termEntries: [
|
||||||
|
[
|
||||||
|
'アレクシア',
|
||||||
|
'あれくしあ',
|
||||||
|
'name primary',
|
||||||
|
'',
|
||||||
|
75,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'structured-content',
|
||||||
|
content: {
|
||||||
|
tag: 'div',
|
||||||
|
content: [
|
||||||
|
{ tag: 'div', content: 'アレクシア・ミドガル' },
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
content: {
|
||||||
|
tag: 'img',
|
||||||
|
path: 'img/m130298-c123.png',
|
||||||
|
alt: 'アレクシア・ミドガル',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'details',
|
||||||
|
content: [
|
||||||
|
{ tag: 'summary', content: 'Voiced by' },
|
||||||
|
{
|
||||||
|
tag: 'div',
|
||||||
|
content: {
|
||||||
|
tag: 'img',
|
||||||
|
path: 'img/m130298-va456.png',
|
||||||
|
alt: 'VA',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
{ path: 'img/m130298-c123.png', dataBase64: 'AAAA' },
|
||||||
|
{ path: 'img/m130298-va456.png', dataBase64: 'BBBB' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||||
|
|
||||||
|
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||||
|
|
||||||
|
assert.deepEqual(index.get('アレクシア'), {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アレクシア・ミドガル',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCharacterNameImageIndexFromSnapshots sniffs image MIME from bytes before path extension', () => {
|
||||||
|
const outputDir = makeTempDir();
|
||||||
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 1,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
termEntries: [
|
||||||
|
[
|
||||||
|
'アレクシア',
|
||||||
|
'あれくしあ',
|
||||||
|
'name primary',
|
||||||
|
'',
|
||||||
|
75,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'structured-content',
|
||||||
|
content: {
|
||||||
|
tag: 'img',
|
||||||
|
path: 'img/m130298-c123.jpg',
|
||||||
|
alt: 'アレクシア・ミドガル',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
images: [{ path: 'img/m130298-c123.jpg', dataBase64: PNG_1X1_BASE64 }],
|
||||||
|
};
|
||||||
|
writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||||
|
|
||||||
|
const index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||||
|
|
||||||
|
assert.equal(index.get('アレクシア')?.src, `data:image/png;base64,${PNG_1X1_BASE64}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { CharacterNameImage } from '../../types';
|
||||||
|
import { readCachedSnapshots } from './cache';
|
||||||
|
import type {
|
||||||
|
CharacterDictionaryGlossaryEntry,
|
||||||
|
CharacterDictionarySnapshot,
|
||||||
|
CharacterDictionarySnapshotImage,
|
||||||
|
CharacterDictionaryTermEntry,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const CHARACTER_IMAGE_PATH_PATTERN = /^img\/m\d+-c\d+\.[a-z0-9]+$/i;
|
||||||
|
|
||||||
|
type StructuredContentNode = {
|
||||||
|
tag?: unknown;
|
||||||
|
path?: unknown;
|
||||||
|
alt?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLookupTerm(term: string): string {
|
||||||
|
return term.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotsDir(outputDir: string): string {
|
||||||
|
return path.join(outputDir, 'snapshots');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageMimeType(imagePath: string, dataBase64: string): string {
|
||||||
|
const signature = Buffer.from(dataBase64.slice(0, 64), 'base64');
|
||||||
|
if (
|
||||||
|
signature.length >= 8 &&
|
||||||
|
signature[0] === 0x89 &&
|
||||||
|
signature[1] === 0x50 &&
|
||||||
|
signature[2] === 0x4e &&
|
||||||
|
signature[3] === 0x47
|
||||||
|
) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
signature.length >= 12 &&
|
||||||
|
signature.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
signature.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
signature.length >= 6 &&
|
||||||
|
(signature.subarray(0, 6).toString('ascii') === 'GIF89a' ||
|
||||||
|
signature.subarray(0, 6).toString('ascii') === 'GIF87a')
|
||||||
|
) {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
if (signature.length >= 3 && signature[0] === 0xff && signature[1] === 0xd8) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
signature.length >= 12 &&
|
||||||
|
signature.subarray(4, 8).toString('ascii') === 'ftyp' &&
|
||||||
|
signature.subarray(8, 12).toString('ascii') === 'avif'
|
||||||
|
) {
|
||||||
|
return 'image/avif';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(imagePath).toLowerCase();
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||||
|
if (ext === '.png') return 'image/png';
|
||||||
|
if (ext === '.webp') return 'image/webp';
|
||||||
|
if (ext === '.gif') return 'image/gif';
|
||||||
|
if (ext === '.avif') return 'image/avif';
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImageByPath(
|
||||||
|
images: ReadonlyArray<CharacterDictionarySnapshotImage>,
|
||||||
|
): Map<string, CharacterDictionarySnapshotImage> {
|
||||||
|
const imageByPath = new Map<string, CharacterDictionarySnapshotImage>();
|
||||||
|
for (const image of images) {
|
||||||
|
if (image.path && image.dataBase64) {
|
||||||
|
imageByPath.set(image.path, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imageByPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCharacterImageNode(value: unknown): StructuredContentNode | null {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const found = findCharacterImageNode(item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = value as StructuredContentNode;
|
||||||
|
if (
|
||||||
|
node.tag === 'img' &&
|
||||||
|
typeof node.path === 'string' &&
|
||||||
|
CHARACTER_IMAGE_PATH_PATTERN.test(node.path)
|
||||||
|
) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findCharacterImageNode(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCharacterImageNodeInGlossary(
|
||||||
|
glossary: ReadonlyArray<CharacterDictionaryGlossaryEntry>,
|
||||||
|
): StructuredContentNode | null {
|
||||||
|
for (const entry of glossary) {
|
||||||
|
const found = findCharacterImageNode(entry);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharacterNameImage(
|
||||||
|
entry: CharacterDictionaryTermEntry,
|
||||||
|
imageByPath: ReadonlyMap<string, CharacterDictionarySnapshotImage>,
|
||||||
|
): CharacterNameImage | null {
|
||||||
|
const term = normalizeLookupTerm(entry[0]);
|
||||||
|
if (!term) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageNode = findCharacterImageNodeInGlossary(entry[5]);
|
||||||
|
const imagePath = typeof imageNode?.path === 'string' ? imageNode.path : '';
|
||||||
|
const image = imageByPath.get(imagePath);
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAlt =
|
||||||
|
typeof imageNode?.alt === 'string'
|
||||||
|
? imageNode.alt
|
||||||
|
: typeof imageNode?.title === 'string'
|
||||||
|
? imageNode.title
|
||||||
|
: term;
|
||||||
|
const alt = rawAlt.trim() || term;
|
||||||
|
return {
|
||||||
|
src: `data:${getImageMimeType(image.path, image.dataBase64)};base64,${image.dataBase64}`,
|
||||||
|
alt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSnapshotImages(
|
||||||
|
index: Map<string, CharacterNameImage>,
|
||||||
|
snapshot: CharacterDictionarySnapshot,
|
||||||
|
): void {
|
||||||
|
const imageByPath = buildImageByPath(snapshot.images);
|
||||||
|
for (const entry of snapshot.termEntries) {
|
||||||
|
const term = normalizeLookupTerm(entry[0]);
|
||||||
|
if (!term || index.has(term)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const image = createCharacterNameImage(entry, imageByPath);
|
||||||
|
if (image) {
|
||||||
|
index.set(term, image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotHasCharacterNameImages(snapshot: CharacterDictionarySnapshot): boolean {
|
||||||
|
const imageByPath = buildImageByPath(snapshot.images);
|
||||||
|
return snapshot.termEntries.some(
|
||||||
|
(entry) => createCharacterNameImage(entry, imageByPath) !== null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshotDirectorySignature(outputDir: string): string {
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !/^anilist-\d+\.json$/.test(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const snapshotPath = path.join(getSnapshotsDir(outputDir), entry.name);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(snapshotPath);
|
||||||
|
parts.push(`${entry.name}:${stat.mtimeMs}:${stat.size}`);
|
||||||
|
} catch {
|
||||||
|
// Ignore files that disappear during refresh; next lookup will rebuild.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.sort().join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCharacterNameImageIndexFromSnapshots(
|
||||||
|
outputDir: string,
|
||||||
|
): Map<string, CharacterNameImage> {
|
||||||
|
const index = new Map<string, CharacterNameImage>();
|
||||||
|
for (const snapshot of readCachedSnapshots(outputDir)) {
|
||||||
|
appendSnapshotImages(index, snapshot);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCharacterDictionaryImageLookup(deps: {
|
||||||
|
userDataPath?: string;
|
||||||
|
outputDir?: string;
|
||||||
|
}): {
|
||||||
|
get: (term: string) => CharacterNameImage | null;
|
||||||
|
invalidate: () => void;
|
||||||
|
} {
|
||||||
|
const outputDir =
|
||||||
|
deps.outputDir ??
|
||||||
|
(deps.userDataPath ? path.join(deps.userDataPath, 'character-dictionaries') : '');
|
||||||
|
let signature: string | null = null;
|
||||||
|
let index = new Map<string, CharacterNameImage>();
|
||||||
|
|
||||||
|
function refreshIfNeeded(): void {
|
||||||
|
if (!outputDir) {
|
||||||
|
index = new Map<string, CharacterNameImage>();
|
||||||
|
signature = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||||
|
if (nextSignature === signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signature = nextSignature;
|
||||||
|
index = buildCharacterNameImageIndexFromSnapshots(outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(term: string): CharacterNameImage | null {
|
||||||
|
const normalizedTerm = normalizeLookupTerm(term);
|
||||||
|
if (!normalizedTerm) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
refreshIfNeeded();
|
||||||
|
return index.get(normalizedTerm) ?? null;
|
||||||
|
},
|
||||||
|
invalidate(): void {
|
||||||
|
signature = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||||
|
import { buildCharacterDictionarySeriesKey } from './manual-selection';
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getManualSelectionSnapshot waits for explicit search text before fetching candidates', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const searchTerms: string[] = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
variables?: { search?: string };
|
||||||
|
};
|
||||||
|
searchTerms.push(String(body.variables?.search ?? ''));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 154587,
|
||||||
|
episodes: 28,
|
||||||
|
title: {
|
||||||
|
romaji: 'Sousou no Frieren',
|
||||||
|
english: 'Frieren: Beyond Journey’s End',
|
||||||
|
native: '葬送のフリーレン',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||||
|
getCurrentMediaTitle: () => '[SubsPlease] Kage no Jitsuryokusha - 05.mkv',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
season: null,
|
||||||
|
episode: 5,
|
||||||
|
source: 'guessit',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = await runtime.getManualSelectionSnapshot(undefined, '');
|
||||||
|
assert.equal(initial.guessTitle, 'Kage no Jitsuryokusha ni Naritakute!');
|
||||||
|
assert.deepEqual(initial.candidates, []);
|
||||||
|
assert.deepEqual(searchTerms, []);
|
||||||
|
|
||||||
|
const searched = await runtime.getManualSelectionSnapshot(undefined, 'Frieren');
|
||||||
|
assert.deepEqual(searchTerms, ['Frieren']);
|
||||||
|
assert.deepEqual(searched.candidates, [
|
||||||
|
{ id: 154587, title: 'Frieren: Beyond Journey’s End', episodes: 28 },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getManualSelectionSnapshot hydrates override episode count from searched candidates', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const overrideSeriesKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: '/tmp/KonoSuba - 01.mkv',
|
||||||
|
mediaTitle: 'KonoSuba - 01.mkv',
|
||||||
|
guess: {
|
||||||
|
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||||
|
year: 2016,
|
||||||
|
season: null,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(overrideDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(overrideDir, 'anilist-overrides.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
seriesKey: overrideSeriesKey,
|
||||||
|
mediaId: 21202,
|
||||||
|
mediaTitle: "KONOSUBA -God's blessing on this wonderful world!",
|
||||||
|
staleMediaIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_input: string | URL | Request) => {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 21202,
|
||||||
|
episodes: 10,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||||
|
english: "KONOSUBA -God's blessing on this wonderful world!",
|
||||||
|
native: 'この素晴らしい世界に祝福を!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/KonoSuba - 01.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'KonoSuba - 01.mkv',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: "KonoSuba - God's blessing on this wonderful world!",
|
||||||
|
year: 2016,
|
||||||
|
season: null,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await runtime.getManualSelectionSnapshot(undefined, 'KonoSuba');
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot.override, {
|
||||||
|
id: 21202,
|
||||||
|
title: "KONOSUBA -God's blessing on this wonderful world!",
|
||||||
|
episodes: 10,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -10,15 +10,17 @@ import {
|
|||||||
} from './manual-selection';
|
} from './manual-selection';
|
||||||
|
|
||||||
const REZERO_EP1 =
|
const REZERO_EP1 =
|
||||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv';
|
||||||
const REZERO_EP2 =
|
const REZERO_EP2 =
|
||||||
'/anime/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
'/anime/ReZERO/Season 1/Re - ZERO, Starting Life in Another World (2016) - S01E02 - Reunion with the Witch [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||||
|
const REZERO_S2_EP1 =
|
||||||
|
'/anime/ReZERO/Season 2/Re - ZERO, Starting Life in Another World (2016) - S02E01 - Each Ones Promise [Bluray-1080p][x265][JA]-SCY.mkv';
|
||||||
|
|
||||||
function makeTempDir(): string {
|
function makeTempDir(): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-manual-selection-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, and year for Re ZERO series scope', () => {
|
test('buildCharacterDictionarySeriesKey scopes guessit title and year by media directory', () => {
|
||||||
const key = buildCharacterDictionarySeriesKey({
|
const key = buildCharacterDictionarySeriesKey({
|
||||||
mediaPath: REZERO_EP1,
|
mediaPath: REZERO_EP1,
|
||||||
mediaTitle: null,
|
mediaTitle: null,
|
||||||
@@ -32,10 +34,10 @@ test('buildCharacterDictionarySeriesKey uses guessit title, alternative title, a
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(key, 're-zero-starting-life-in-another-world-2016');
|
assert.equal(key, 'anime-rezero-season-1--re-zero-starting-life-in-another-world-2016');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual selection store persists overrides and matches later episodes in the same series', async () => {
|
test('manual selection store persists overrides and matches later episodes in the same directory', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
const firstKey = buildCharacterDictionarySeriesKey({
|
const firstKey = buildCharacterDictionarySeriesKey({
|
||||||
@@ -79,3 +81,131 @@ test('manual selection store persists overrides and matches later episodes in th
|
|||||||
staleMediaIds: [10607],
|
staleMediaIds: [10607],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual selection store resolves legacy unscoped override keys', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(overrideDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(overrideDir, 'anilist-overrides.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
|
mediaId: 21355,
|
||||||
|
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||||
|
staleMediaIds: [10607],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopedKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: REZERO_EP1,
|
||||||
|
mediaTitle: null,
|
||||||
|
guess: {
|
||||||
|
title: 'Re ZERO, Starting Life in Another World',
|
||||||
|
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||||
|
year: 2016,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
|
|
||||||
|
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||||
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
|
mediaId: 21355,
|
||||||
|
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||||
|
staleMediaIds: [10607],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual selection store prefers exact scoped override over legacy fallback', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(overrideDir, { recursive: true });
|
||||||
|
const scopedKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: REZERO_EP1,
|
||||||
|
mediaTitle: null,
|
||||||
|
guess: {
|
||||||
|
title: 'Re ZERO, Starting Life in Another World',
|
||||||
|
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||||
|
year: 2016,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(overrideDir, 'anilist-overrides.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
|
mediaId: 10607,
|
||||||
|
mediaTitle: 'Legacy Re:ZERO',
|
||||||
|
staleMediaIds: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seriesKey: scopedKey,
|
||||||
|
mediaId: 21355,
|
||||||
|
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||||
|
staleMediaIds: [10607],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
|
|
||||||
|
assert.deepEqual(await store.getOverride(scopedKey), {
|
||||||
|
seriesKey: scopedKey,
|
||||||
|
mediaId: 21355,
|
||||||
|
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||||
|
staleMediaIds: [10607],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual selection store keeps overrides separate for different season directories', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
|
const firstSeasonKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: REZERO_EP1,
|
||||||
|
mediaTitle: null,
|
||||||
|
guess: {
|
||||||
|
title: 'Re ZERO, Starting Life in Another World',
|
||||||
|
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||||
|
year: 2016,
|
||||||
|
season: 1,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await store.setOverride({
|
||||||
|
seriesKey: firstSeasonKey,
|
||||||
|
mediaId: 21355,
|
||||||
|
mediaTitle: 'Re:ZERO -Starting Life in Another World-',
|
||||||
|
staleMediaIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondSeasonKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: REZERO_S2_EP1,
|
||||||
|
mediaTitle: null,
|
||||||
|
guess: {
|
||||||
|
title: 'Re ZERO, Starting Life in Another World',
|
||||||
|
alternativeTitle: 'ZERO, Starting Life in Another World',
|
||||||
|
year: 2016,
|
||||||
|
season: 2,
|
||||||
|
episode: 1,
|
||||||
|
source: 'guessit',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
||||||
|
assert.equal(await store.getOverride(secondSeasonKey), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ function normalizeSeriesKeyPart(value: string): string {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMediaDirectoryKey(mediaPath: string | null): string {
|
||||||
|
const rawPath = mediaPath?.trim();
|
||||||
|
if (!rawPath) return '';
|
||||||
|
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(rawPath) || rawPath.startsWith('file:')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(rawPath);
|
||||||
|
const directoryPath = path.posix.dirname(
|
||||||
|
decodeURIComponent(url.pathname).replace(/\\/g, '/'),
|
||||||
|
);
|
||||||
|
const scopedPath = `${url.hostname}${directoryPath === '/' ? '' : directoryPath}`;
|
||||||
|
return normalizeSeriesKeyPart(scopedPath);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = rawPath.replace(/\\/g, '/');
|
||||||
|
const directoryPath = path.posix.dirname(normalizedPath);
|
||||||
|
if (!directoryPath || directoryPath === '.') return '';
|
||||||
|
return normalizeSeriesKeyPart(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeNumbers(values: number[]): number[] {
|
function dedupeNumbers(values: number[]): number[] {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const result: number[] = [];
|
const result: number[] = [];
|
||||||
@@ -78,6 +101,12 @@ function writeOverrides(filePath: string, overrides: CharacterDictionaryManualSe
|
|||||||
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
fs.writeFileSync(filePath, JSON.stringify({ overrides }, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
|
||||||
|
const scopedSeparatorIndex = seriesKey.indexOf('--');
|
||||||
|
if (scopedSeparatorIndex < 0) return [seriesKey];
|
||||||
|
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCharacterDictionarySeriesKey(input: {
|
export function buildCharacterDictionarySeriesKey(input: {
|
||||||
mediaPath: string | null;
|
mediaPath: string | null;
|
||||||
mediaTitle: string | null;
|
mediaTitle: string | null;
|
||||||
@@ -94,7 +123,9 @@ export function buildCharacterDictionarySeriesKey(input: {
|
|||||||
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
.replace(/\bepisode\s+\d+\b/gi, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||||
return input.guess?.year ? `${base}-${input.guess.year}` : base;
|
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
||||||
|
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
||||||
|
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||||
@@ -102,7 +133,13 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||||
return readOverrides(filePath).find((entry) => entry.seriesKey === seriesKey) ?? null;
|
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||||
|
const overrides = readOverrides(filePath);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
setOverride: async (selection: CharacterDictionaryManualSelection): Promise<void> => {
|
||||||
const normalized = normalizeOverride(selection);
|
const normalized = normalizeOverride(selection);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||||
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
|
import type { CharacterDictionarySnapshot } from './types';
|
||||||
|
|
||||||
|
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
const PNG_1X1 = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSnapshotWithoutImages(): CharacterDictionarySnapshot {
|
||||||
|
return {
|
||||||
|
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 1,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
termEntries: [['アレクシア', 'あれくしあ', 'name primary', '', 75, ['Alexia'], 0, '']],
|
||||||
|
images: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateForCurrentMedia refreshes same-version snapshots missing images when inline images are enabled', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchUrls: string[] = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
fetchUrls.push(url);
|
||||||
|
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'SUPPORTING',
|
||||||
|
node: {
|
||||||
|
id: 123,
|
||||||
|
description: 'Alexia Midgar.',
|
||||||
|
image: {
|
||||||
|
large: 'https://cdn.example.com/character-123.png',
|
||||||
|
medium: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
full: 'Alexia Midgar',
|
||||||
|
native: 'アレクシア・ミドガル',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === 'https://cdn.example.com/character-123.png') {
|
||||||
|
return new Response(PNG_1X1, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/png' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
season: null,
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
getNameMatchImagesEnabled: () => true,
|
||||||
|
now: () => 1_700_000_000_500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const refreshedSnapshot = JSON.parse(
|
||||||
|
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||||
|
) as CharacterDictionarySnapshot;
|
||||||
|
|
||||||
|
assert.equal(result.fromCache, false);
|
||||||
|
assert.ok(fetchUrls.includes(GRAPHQL_URL));
|
||||||
|
assert.ok(refreshedSnapshot.images.some((image) => image.path === 'img/m130298-c123.png'));
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
writeSnapshot(getSnapshotPath(outputDir, 130298), createSnapshotWithoutImages());
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
season: null,
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
getNameMatchImagesEnabled: () => false,
|
||||||
|
now: () => 1_700_000_000_500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
|
||||||
|
assert.equal(result.fromCache, true);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import type { AnilistCharacterDictionaryCollapsibleSectionKey } from '../../type
|
|||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
import { createDefinitionGlossary } from './glossary';
|
import { createDefinitionGlossary } from './glossary';
|
||||||
import { generateNameReadings, splitJapaneseName } from './name-reading';
|
import { generateNameReadings, splitJapaneseName } from './name-reading';
|
||||||
import { buildNameTerms, buildReadingForTerm, buildTermEntry } from './term-building';
|
import {
|
||||||
|
buildNameTerms,
|
||||||
|
buildReadingForTerm,
|
||||||
|
buildTermEntry,
|
||||||
|
buildVisibleNameTerms,
|
||||||
|
} from './term-building';
|
||||||
import type {
|
import type {
|
||||||
CharacterDictionaryGlossaryEntry,
|
CharacterDictionaryGlossaryEntry,
|
||||||
CharacterDictionarySnapshot,
|
CharacterDictionarySnapshot,
|
||||||
@@ -40,14 +45,15 @@ export function buildSnapshotFromCharacters(
|
|||||||
const vaImg = imagesByVaId.get(va.id);
|
const vaImg = imagesByVaId.get(va.id);
|
||||||
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
|
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
|
||||||
}
|
}
|
||||||
|
const candidateTerms = buildNameTerms(character);
|
||||||
const glossary = createDefinitionGlossary(
|
const glossary = createDefinitionGlossary(
|
||||||
character,
|
character,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
imagePath,
|
imagePath,
|
||||||
vaImagePaths,
|
vaImagePaths,
|
||||||
|
buildVisibleNameTerms(candidateTerms),
|
||||||
getCollapsibleSectionOpenState,
|
getCollapsibleSectionOpenState,
|
||||||
);
|
);
|
||||||
const candidateTerms = buildNameTerms(character);
|
|
||||||
const nameParts = splitJapaneseName(
|
const nameParts = splitJapaneseName(
|
||||||
character.nativeName,
|
character.nativeName,
|
||||||
character.firstNameHint,
|
character.firstNameHint,
|
||||||
|
|||||||
@@ -41,25 +41,27 @@ function expandRawNameVariants(rawName: string): string[] {
|
|||||||
|
|
||||||
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 rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
||||||
for (const rawName of rawNames) {
|
for (const rawName of rawNames) {
|
||||||
for (const name of expandRawNameVariants(rawName)) {
|
for (const name of expandRawNameVariants(rawName)) {
|
||||||
base.add(name);
|
const target = isRomanizedName(name) ? romanizedBase : base;
|
||||||
|
target.add(name);
|
||||||
|
|
||||||
const compact = name.replace(/[\s\u3000]+/g, '');
|
const compact = name.replace(/[\s\u3000]+/g, '');
|
||||||
if (compact && compact !== name) {
|
if (compact && compact !== name) {
|
||||||
base.add(compact);
|
target.add(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noMiddleDots = compact.replace(/[・・·•]/g, '');
|
const noMiddleDots = compact.replace(/[・・·•]/g, '');
|
||||||
if (noMiddleDots && noMiddleDots !== compact) {
|
if (noMiddleDots && noMiddleDots !== compact) {
|
||||||
base.add(noMiddleDots);
|
target.add(noMiddleDots);
|
||||||
}
|
}
|
||||||
|
|
||||||
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
||||||
if (split.length === 2) {
|
if (split.length === 2) {
|
||||||
base.add(split[0]!);
|
target.add(split[0]!);
|
||||||
base.add(split[1]!);
|
target.add(split[1]!);
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitByMiddleDot = name
|
const splitByMiddleDot = name
|
||||||
@@ -68,12 +70,16 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
|||||||
.filter((part) => part.length > 0);
|
.filter((part) => part.length > 0);
|
||||||
if (splitByMiddleDot.length >= 2) {
|
if (splitByMiddleDot.length >= 2) {
|
||||||
for (const part of splitByMiddleDot) {
|
for (const part of splitByMiddleDot) {
|
||||||
base.add(part);
|
target.add(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const alias of addRomanizedKanaAliases(romanizedBase)) {
|
||||||
|
base.add(alias);
|
||||||
|
}
|
||||||
|
|
||||||
const nativeParts = splitJapaneseName(
|
const nativeParts = splitJapaneseName(
|
||||||
character.nativeName,
|
character.nativeName,
|
||||||
character.firstNameHint,
|
character.firstNameHint,
|
||||||
@@ -94,14 +100,22 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
|
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
||||||
withHonorifics.add(alias);
|
|
||||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
|
||||||
withHonorifics.add(`${alias}${suffix.term}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
export function buildVisibleNameTerms(nameTerms: string[]): string[] {
|
||||||
|
const allTerms = new Set(nameTerms);
|
||||||
|
return nameTerms.filter((term) => {
|
||||||
|
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||||
|
if (!term.endsWith(suffix.term) || term.length <= suffix.term.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (allTerms.has(term.slice(0, -suffix.term.length))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReadingForTerm(
|
export function buildReadingForTerm(
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export interface CharacterDictionaryRuntimeDeps {
|
|||||||
sleep?: (ms: number) => Promise<void>;
|
sleep?: (ms: number) => Promise<void>;
|
||||||
logInfo?: (message: string) => void;
|
logInfo?: (message: string) => void;
|
||||||
logWarn?: (message: string) => void;
|
logWarn?: (message: string) => void;
|
||||||
|
getNameMatchImagesEnabled?: () => boolean;
|
||||||
getCollapsibleSectionOpenState?: (
|
getCollapsibleSectionOpenState?: (
|
||||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ function hasAnnotationRuntimeHotReload(diff: ConfigHotReloadDiff): boolean {
|
|||||||
'ankiConnect.knownWords',
|
'ankiConnect.knownWords',
|
||||||
'ankiConnect.nPlusOne',
|
'ankiConnect.nPlusOne',
|
||||||
'ankiConnect.fields.word',
|
'ankiConnect.fields.word',
|
||||||
|
'subtitleStyle.nameMatchEnabled',
|
||||||
|
'subtitleStyle.nameMatchImagesEnabled',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
|||||||
getJlptLevel: () => 'N2',
|
getJlptLevel: () => 'N2',
|
||||||
getJlptEnabled: () => true,
|
getJlptEnabled: () => true,
|
||||||
getNameMatchEnabled: () => false,
|
getNameMatchEnabled: () => false,
|
||||||
|
getNameMatchImagesEnabled: () => true,
|
||||||
|
getCharacterNameImage: (term) =>
|
||||||
|
term === 'name' ? { src: 'data:image/png;base64,AAAA', alt: 'Name' } : null,
|
||||||
getFrequencyDictionaryEnabled: () => true,
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||||
getFrequencyRank: () => 5,
|
getFrequencyRank: () => 5,
|
||||||
@@ -52,6 +55,11 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
|
|||||||
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
assert.equal(deps.getNPlusOneEnabled?.(), true);
|
||||||
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
|
||||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||||
|
assert.equal(deps.getNameMatchImagesEnabled?.(), true);
|
||||||
|
assert.deepEqual(deps.getCharacterNameImage?.('name'), {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'Name',
|
||||||
|
});
|
||||||
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
|
assert.equal(deps.getFrequencyDictionaryMatchMode?.(), 'surface');
|
||||||
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
|
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
|
||||||
});
|
});
|
||||||
@@ -74,6 +82,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
|||||||
getJlptEnabled: () => true,
|
getJlptEnabled: () => true,
|
||||||
getCharacterDictionaryEnabled: () => false,
|
getCharacterDictionaryEnabled: () => false,
|
||||||
getNameMatchEnabled: () => true,
|
getNameMatchEnabled: () => true,
|
||||||
|
getNameMatchImagesEnabled: () => true,
|
||||||
getFrequencyDictionaryEnabled: () => true,
|
getFrequencyDictionaryEnabled: () => true,
|
||||||
getFrequencyDictionaryMatchMode: () => 'surface',
|
getFrequencyDictionaryMatchMode: () => 'surface',
|
||||||
getFrequencyRank: () => 5,
|
getFrequencyRank: () => 5,
|
||||||
@@ -82,6 +91,7 @@ test('tokenizer deps builder disables name matching when character dictionary is
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
assert.equal(deps.getNameMatchEnabled?.(), false);
|
assert.equal(deps.getNameMatchEnabled?.(), false);
|
||||||
|
assert.equal(deps.getNameMatchImagesEnabled?.(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
|
test('mecab tokenizer check creates tokenizer once and runs availability check', async () => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
|
|||||||
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
|
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
|
||||||
getCharacterDictionaryEnabled?: () => boolean;
|
getCharacterDictionaryEnabled?: () => boolean;
|
||||||
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
getNameMatchEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchEnabled']>;
|
||||||
|
getNameMatchImagesEnabled?: NonNullable<TokenizerDepsRuntimeOptions['getNameMatchImagesEnabled']>;
|
||||||
|
getCharacterNameImage?: NonNullable<TokenizerDepsRuntimeOptions['getCharacterNameImage']>;
|
||||||
getFrequencyDictionaryEnabled: NonNullable<
|
getFrequencyDictionaryEnabled: NonNullable<
|
||||||
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
|
||||||
>;
|
>;
|
||||||
@@ -57,6 +59,17 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
|
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchEnabled!(),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(deps.getNameMatchImagesEnabled
|
||||||
|
? {
|
||||||
|
getNameMatchImagesEnabled: () =>
|
||||||
|
deps.getCharacterDictionaryEnabled?.() !== false && deps.getNameMatchImagesEnabled!(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(deps.getCharacterNameImage
|
||||||
|
? {
|
||||||
|
getCharacterNameImage: (term: string) => deps.getCharacterNameImage!(term),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
|
||||||
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
getFrequencyDictionaryMatchMode: () => deps.getFrequencyDictionaryMatchMode(),
|
||||||
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
|
||||||
|
|||||||
+2
-2
@@ -413,8 +413,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
): Promise<YoutubePickerResolveResult> =>
|
): Promise<YoutubePickerResolveResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.youtubePickerResolve, request),
|
||||||
getCharacterDictionarySelection: () =>
|
getCharacterDictionarySelection: (searchTitle?: string) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCharacterDictionarySelection, searchTitle),
|
||||||
setCharacterDictionarySelection: (mediaId: number) =>
|
setCharacterDictionarySelection: (mediaId: number) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
|
ipcRenderer.invoke(IPC_CHANNELS.request.setCharacterDictionarySelection, mediaId),
|
||||||
notifyOverlayModalClosed: (modal) => {
|
notifyOverlayModalClosed: (modal) => {
|
||||||
|
|||||||
@@ -681,6 +681,7 @@ test('numeric selection start focuses overlay for follow-up digit keys', async (
|
|||||||
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
assert.equal(testGlobals.windowFocusCalls() > 0, true);
|
||||||
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
assert.equal(testGlobals.overlayFocusCalls.length > 0, true);
|
||||||
} finally {
|
} finally {
|
||||||
|
testGlobals.dispatchKeydown({ key: 'Escape', code: 'Escape' });
|
||||||
testGlobals.restore();
|
testGlobals.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-1
@@ -22,7 +22,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; worker-src 'self' blob:;"
|
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:; img-src 'self' data: blob: chrome-extension:; worker-src 'self' blob:;"
|
||||||
/>
|
/>
|
||||||
<title>SubMiner</title>
|
<title>SubMiner</title>
|
||||||
<link rel="stylesheet" href="style.css" />
|
<link rel="stylesheet" href="style.css" />
|
||||||
@@ -205,6 +205,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
<div id="characterDictionarySummary" class="runtime-options-hint"></div>
|
||||||
|
<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>
|
<div id="characterDictionaryCurrent" class="character-dictionary-current"></div>
|
||||||
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
<ul id="characterDictionaryCandidates" class="character-dictionary-candidates"></ul>
|
||||||
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
<div id="characterDictionaryStatus" class="runtime-options-status"></div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ function createElementStub() {
|
|||||||
className: '',
|
className: '',
|
||||||
textContent: '',
|
textContent: '',
|
||||||
type: '',
|
type: '',
|
||||||
|
value: '',
|
||||||
|
disabled: false,
|
||||||
children: [] as unknown[],
|
children: [] as unknown[],
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
append(...children: unknown[]) {
|
append(...children: unknown[]) {
|
||||||
@@ -38,17 +40,25 @@ function createElementStub() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createNodeStub(hidden = false) {
|
function createNodeStub(hidden = false) {
|
||||||
const listeners = new Map<string, Array<() => void>>();
|
const listeners = new Map<string, Array<(event?: { preventDefault?: () => void }) => void>>();
|
||||||
return {
|
return {
|
||||||
textContent: '',
|
textContent: '',
|
||||||
|
value: '',
|
||||||
|
disabled: false,
|
||||||
children: [] as unknown[],
|
children: [] as unknown[],
|
||||||
classList: createClassList(hidden ? ['hidden'] : []),
|
classList: createClassList(hidden ? ['hidden'] : []),
|
||||||
setAttribute: () => {},
|
setAttribute: () => {},
|
||||||
addEventListener: (event: string, listener: () => void) => {
|
addEventListener: (
|
||||||
|
event: string,
|
||||||
|
listener: (event?: { preventDefault?: () => void }) => void,
|
||||||
|
) => {
|
||||||
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||||
},
|
},
|
||||||
dispatchEvent: (event: string) => {
|
dispatchEvent: (event: string, payload?: { preventDefault?: () => void }) => {
|
||||||
for (const listener of listeners.get(event) ?? []) listener();
|
for (const listener of listeners.get(event) ?? []) listener(payload);
|
||||||
|
},
|
||||||
|
append(...children: unknown[]) {
|
||||||
|
this.children.push(...children);
|
||||||
},
|
},
|
||||||
replaceChildren(...children: unknown[]) {
|
replaceChildren(...children: unknown[]) {
|
||||||
this.children = [...children];
|
this.children = [...children];
|
||||||
@@ -207,6 +217,8 @@ test('character dictionary modal loads candidates and applies selected override'
|
|||||||
characterDictionaryClose: closeButton,
|
characterDictionaryClose: closeButton,
|
||||||
characterDictionarySummary: createNodeStub(),
|
characterDictionarySummary: createNodeStub(),
|
||||||
characterDictionaryCurrent: createNodeStub(),
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
characterDictionaryCandidates: candidates,
|
characterDictionaryCandidates: candidates,
|
||||||
characterDictionaryStatus: status,
|
characterDictionaryStatus: status,
|
||||||
},
|
},
|
||||||
@@ -283,6 +295,8 @@ test('character dictionary modal shows refresh errors without rejecting open', a
|
|||||||
characterDictionaryClose: createNodeStub(),
|
characterDictionaryClose: createNodeStub(),
|
||||||
characterDictionarySummary: createNodeStub(),
|
characterDictionarySummary: createNodeStub(),
|
||||||
characterDictionaryCurrent: createNodeStub(),
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
characterDictionaryCandidates: createNodeStub(),
|
characterDictionaryCandidates: createNodeStub(),
|
||||||
characterDictionaryStatus: status,
|
characterDictionaryStatus: status,
|
||||||
},
|
},
|
||||||
@@ -302,3 +316,255 @@ test('character dictionary modal shows refresh errors without rejecting open', a
|
|||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('character dictionary modal seeds search input and waits for manual search', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
const initialSnapshot: CharacterDictionarySelectionSnapshot = {
|
||||||
|
seriesKey: 'kage-no-jitsuryokusha-ni-naritakute-2022',
|
||||||
|
guessTitle: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
current: null,
|
||||||
|
override: null,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
const searchedSnapshot: CharacterDictionarySelectionSnapshot = {
|
||||||
|
...initialSnapshot,
|
||||||
|
candidates: [{ id: 130298, title: 'The Eminence in Shadow', episodes: 20 }],
|
||||||
|
};
|
||||||
|
const searches: Array<string | undefined> = [];
|
||||||
|
const overlay = createNodeStub();
|
||||||
|
const searchInput = createNodeStub();
|
||||||
|
const searchButton = createNodeStub();
|
||||||
|
const candidates = createNodeStub();
|
||||||
|
const status = createNodeStub();
|
||||||
|
const state = createRendererState();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionarySelection: async (searchText?: string) => {
|
||||||
|
searches.push(searchText);
|
||||||
|
return searchText ? searchedSnapshot : initialSnapshot;
|
||||||
|
},
|
||||||
|
setCharacterDictionarySelection: async () => ({
|
||||||
|
ok: true,
|
||||||
|
seriesKey: initialSnapshot.seriesKey,
|
||||||
|
selected: searchedSnapshot.candidates[0]!,
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} satisfies Pick<
|
||||||
|
ElectronAPI,
|
||||||
|
| 'getCharacterDictionarySelection'
|
||||||
|
| 'setCharacterDictionarySelection'
|
||||||
|
| 'notifyOverlayModalClosed'
|
||||||
|
| 'notifyOverlayModalOpened'
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createElementStub(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay,
|
||||||
|
characterDictionaryModal: createNodeStub(true),
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: createNodeStub(),
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: searchInput,
|
||||||
|
characterDictionarySearchButton: searchButton,
|
||||||
|
characterDictionaryCandidates: candidates,
|
||||||
|
characterDictionaryStatus: status,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
modal.wireDomEvents();
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryModal();
|
||||||
|
|
||||||
|
assert.deepEqual(searches, ['']);
|
||||||
|
assert.equal(searchInput.value, 'Kage no Jitsuryokusha ni Naritakute!');
|
||||||
|
assert.equal(candidates.children.length, 1);
|
||||||
|
assert.match(status.textContent, /Enter a title/);
|
||||||
|
|
||||||
|
searchInput.value = 'Eminence in Shadow';
|
||||||
|
searchButton.dispatchEvent('click');
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.deepEqual(searches, ['', 'Eminence in Shadow']);
|
||||||
|
assert.equal(candidates.children.length, 1);
|
||||||
|
assert.match(status.textContent, /Select the correct AniList entry/);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('character dictionary modal marks override candidate as selected', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
const snapshot: CharacterDictionarySelectionSnapshot = {
|
||||||
|
seriesKey: 'konosuba-gods-blessing-on-this-wonderful-world-2016',
|
||||||
|
guessTitle: "KonoSuba - God's blessing on this wonderful world!",
|
||||||
|
current: null,
|
||||||
|
override: {
|
||||||
|
id: 21202,
|
||||||
|
title: "KONOSUBA -God's blessing on this wonderful world!",
|
||||||
|
episodes: 10,
|
||||||
|
},
|
||||||
|
candidates: [
|
||||||
|
{ id: 21202, title: "KONOSUBA -God's blessing on this wonderful world!", episodes: 10 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const state = createRendererState();
|
||||||
|
const candidates = createNodeStub();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionarySelection: async () => snapshot,
|
||||||
|
setCharacterDictionarySelection: async () => ({
|
||||||
|
ok: true,
|
||||||
|
seriesKey: snapshot.seriesKey,
|
||||||
|
selected: snapshot.candidates[0]!,
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} satisfies Pick<
|
||||||
|
ElectronAPI,
|
||||||
|
| 'getCharacterDictionarySelection'
|
||||||
|
| 'setCharacterDictionarySelection'
|
||||||
|
| 'notifyOverlayModalClosed'
|
||||||
|
| 'notifyOverlayModalOpened'
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createElementStub(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay: createNodeStub(),
|
||||||
|
characterDictionaryModal: createNodeStub(true),
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: createNodeStub(),
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
|
characterDictionaryCandidates: candidates,
|
||||||
|
characterDictionaryStatus: createNodeStub(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryModal();
|
||||||
|
|
||||||
|
const item = candidates.children[0] as { children: unknown[] };
|
||||||
|
const button = item.children[1] as { textContent: string; disabled: boolean };
|
||||||
|
assert.equal(button.textContent, 'Selected');
|
||||||
|
assert.equal(button.disabled, true);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('character dictionary modal does not resave the active override from keyboard apply', async () => {
|
||||||
|
const previousWindow = globalThis.window;
|
||||||
|
const snapshot: CharacterDictionarySelectionSnapshot = {
|
||||||
|
seriesKey: 're-zero-starting-life-in-another-world-2016',
|
||||||
|
guessTitle: 'Re ZERO, Starting Life in Another World',
|
||||||
|
current: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||||
|
override: { id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 },
|
||||||
|
candidates: [{ id: 21355, title: 'Re:ZERO -Starting Life in Another World-', episodes: 25 }],
|
||||||
|
};
|
||||||
|
const calls: number[] = [];
|
||||||
|
const state = createRendererState();
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
electronAPI: {
|
||||||
|
getCharacterDictionarySelection: async () => snapshot,
|
||||||
|
setCharacterDictionarySelection: async (mediaId: number) => {
|
||||||
|
calls.push(mediaId);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
seriesKey: snapshot.seriesKey,
|
||||||
|
selected: snapshot.candidates[0]!,
|
||||||
|
staleMediaIds: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
} satisfies Pick<
|
||||||
|
ElectronAPI,
|
||||||
|
| 'getCharacterDictionarySelection'
|
||||||
|
| 'setCharacterDictionarySelection'
|
||||||
|
| 'notifyOverlayModalClosed'
|
||||||
|
| 'notifyOverlayModalOpened'
|
||||||
|
>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modal = createCharacterDictionaryModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom: {
|
||||||
|
overlay: createNodeStub(),
|
||||||
|
characterDictionaryModal: createNodeStub(true),
|
||||||
|
characterDictionaryClose: createNodeStub(),
|
||||||
|
characterDictionarySummary: createNodeStub(),
|
||||||
|
characterDictionaryCurrent: createNodeStub(),
|
||||||
|
characterDictionarySearchInput: createNodeStub(),
|
||||||
|
characterDictionarySearchButton: createNodeStub(),
|
||||||
|
characterDictionaryCandidates: createNodeStub(),
|
||||||
|
characterDictionaryStatus: createNodeStub(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await modal.openCharacterDictionaryModal();
|
||||||
|
modal.handleCharacterDictionaryKeydown({
|
||||||
|
key: 'Enter',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,17 +27,25 @@ export function createCharacterDictionaryModal(
|
|||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
let hasSearched = false;
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
function setStatus(message: string, isError = false): void {
|
||||||
ctx.state.characterDictionaryStatus = message;
|
ctx.state.characterDictionaryStatus = message;
|
||||||
ctx.dom.characterDictionaryStatus.textContent = message;
|
ctx.dom.characterDictionaryStatus.textContent = message;
|
||||||
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
|
ctx.dom.characterDictionaryStatus.classList.toggle('error', isError);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelection(snapshot: CharacterDictionarySelectionSnapshot): void {
|
function setSelection(
|
||||||
|
snapshot: CharacterDictionarySelectionSnapshot,
|
||||||
|
seedSearchInput = false,
|
||||||
|
): void {
|
||||||
const previousId =
|
const previousId =
|
||||||
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
|
ctx.state.characterDictionarySelection?.candidates[ctx.state.characterDictionarySelectedIndex]
|
||||||
?.id;
|
?.id;
|
||||||
ctx.state.characterDictionarySelection = snapshot;
|
ctx.state.characterDictionarySelection = snapshot;
|
||||||
|
if (seedSearchInput) {
|
||||||
|
ctx.dom.characterDictionarySearchInput.value = snapshot.guessTitle ?? '';
|
||||||
|
}
|
||||||
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
|
const nextIndex = snapshot.candidates.findIndex((candidate) => candidate.id === previousId);
|
||||||
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
ctx.state.characterDictionarySelectedIndex = clampIndex(
|
||||||
nextIndex >= 0 ? nextIndex : 0,
|
nextIndex >= 0 ? nextIndex : 0,
|
||||||
@@ -47,6 +55,7 @@ export function createCharacterDictionaryModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
function renderCandidate(candidate: CharacterDictionaryCandidate, index: number): HTMLLIElement {
|
||||||
|
const isOverride = candidate.id === ctx.state.characterDictionarySelection?.override?.id;
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'character-dictionary-candidate';
|
item.className = 'character-dictionary-candidate';
|
||||||
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
|
item.classList.toggle('active', index === ctx.state.characterDictionarySelectedIndex);
|
||||||
@@ -63,9 +72,11 @@ export function createCharacterDictionaryModal(
|
|||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'character-dictionary-use';
|
button.className = 'character-dictionary-use';
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.textContent = 'Use';
|
button.textContent = isOverride ? 'Selected' : 'Use';
|
||||||
|
button.disabled = isOverride;
|
||||||
button.addEventListener('click', (event) => {
|
button.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
if (isOverride) return;
|
||||||
ctx.state.characterDictionarySelectedIndex = index;
|
ctx.state.characterDictionarySelectedIndex = index;
|
||||||
void applySelectedCandidate();
|
void applySelectedCandidate();
|
||||||
});
|
});
|
||||||
@@ -104,7 +115,9 @@ export function createCharacterDictionaryModal(
|
|||||||
if (snapshot.candidates.length === 0) {
|
if (snapshot.candidates.length === 0) {
|
||||||
const empty = document.createElement('li');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'character-dictionary-empty';
|
empty.className = 'character-dictionary-empty';
|
||||||
empty.textContent = 'No AniList candidates found.';
|
empty.textContent = hasSearched
|
||||||
|
? 'No AniList candidates found.'
|
||||||
|
: 'Search AniList to show candidates.';
|
||||||
ctx.dom.characterDictionaryCandidates.append(empty);
|
ctx.dom.characterDictionaryCandidates.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,20 +127,41 @@ export function createCharacterDictionaryModal(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSelection(): Promise<void> {
|
async function refreshSelection(searchTitle?: string): Promise<void> {
|
||||||
const snapshot = await window.electronAPI.getCharacterDictionarySelection();
|
const snapshot = await window.electronAPI.getCharacterDictionarySelection(searchTitle);
|
||||||
setSelection(snapshot);
|
hasSearched = searchTitle !== '';
|
||||||
|
setSelection(snapshot, searchTitle === '');
|
||||||
setStatus(
|
setStatus(
|
||||||
snapshot.override
|
searchTitle === ''
|
||||||
|
? 'Enter a title to search AniList.'
|
||||||
|
: snapshot.override
|
||||||
? `Override active: ${formatCandidate(snapshot.override)}`
|
? `Override active: ${formatCandidate(snapshot.override)}`
|
||||||
: 'Select the correct AniList entry.',
|
: 'Select the correct AniList entry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchCandidates(): Promise<void> {
|
||||||
|
const searchTitle = ctx.dom.characterDictionarySearchInput.value.trim();
|
||||||
|
if (!searchTitle) {
|
||||||
|
setStatus('Enter a title to search AniList.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.dom.characterDictionarySearchButton.disabled = true;
|
||||||
|
setStatus(`Searching AniList for ${searchTitle}...`);
|
||||||
|
try {
|
||||||
|
await refreshSelection(searchTitle);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
} finally {
|
||||||
|
ctx.dom.characterDictionarySearchButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applySelectedCandidate(): Promise<void> {
|
async function applySelectedCandidate(): Promise<void> {
|
||||||
const snapshot = ctx.state.characterDictionarySelection;
|
const snapshot = ctx.state.characterDictionarySelection;
|
||||||
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
|
const candidate = snapshot?.candidates[ctx.state.characterDictionarySelectedIndex];
|
||||||
if (!candidate) return;
|
if (!candidate) return;
|
||||||
|
if (candidate.id === snapshot?.override?.id) return;
|
||||||
|
|
||||||
setStatus(`Saving override for ${candidate.title}...`);
|
setStatus(`Saving override for ${candidate.title}...`);
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +170,7 @@ export function createCharacterDictionaryModal(
|
|||||||
setStatus('Failed to save override', true);
|
setStatus('Failed to save override', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await refreshSelection();
|
await refreshSelection(ctx.dom.characterDictionarySearchInput.value.trim());
|
||||||
const staleLabel =
|
const staleLabel =
|
||||||
result.staleMediaIds.length > 0
|
result.staleMediaIds.length > 0
|
||||||
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
? ` Removed stale: ${result.staleMediaIds.join(', ')}.`
|
||||||
@@ -154,7 +188,7 @@ export function createCharacterDictionaryModal(
|
|||||||
ctx.dom.characterDictionaryModal.classList.remove('hidden');
|
ctx.dom.characterDictionaryModal.classList.remove('hidden');
|
||||||
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
|
ctx.dom.characterDictionaryModal.setAttribute('aria-hidden', 'false');
|
||||||
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
|
window.electronAPI.notifyOverlayModalOpened('character-dictionary');
|
||||||
setStatus('Loading AniList candidates...');
|
setStatus('Loading character dictionary selector...');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCharacterDictionaryModal(): Promise<void> {
|
async function openCharacterDictionaryModal(): Promise<void> {
|
||||||
@@ -165,7 +199,7 @@ export function createCharacterDictionaryModal(
|
|||||||
setStatus('Refreshing AniList candidates...');
|
setStatus('Refreshing AniList candidates...');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await refreshSelection();
|
await refreshSelection('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
}
|
}
|
||||||
@@ -179,6 +213,7 @@ export function createCharacterDictionaryModal(
|
|||||||
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();
|
||||||
|
hasSearched = false;
|
||||||
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
window.electronAPI.notifyOverlayModalClosed('character-dictionary');
|
||||||
setStatus('');
|
setStatus('');
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||||
@@ -202,6 +237,14 @@ export function createCharacterDictionaryModal(
|
|||||||
closeCharacterDictionaryModal();
|
closeCharacterDictionaryModal();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (e.target === ctx.dom.characterDictionarySearchInput) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void searchCandidates();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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);
|
||||||
@@ -222,6 +265,15 @@ export function createCharacterDictionaryModal(
|
|||||||
|
|
||||||
function wireDomEvents(): void {
|
function wireDomEvents(): void {
|
||||||
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
ctx.dom.characterDictionaryClose.addEventListener('click', closeCharacterDictionaryModal);
|
||||||
|
ctx.dom.characterDictionarySearchButton.addEventListener('click', () => {
|
||||||
|
void searchCandidates();
|
||||||
|
});
|
||||||
|
ctx.dom.characterDictionarySearchInput.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void searchCandidates();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -809,6 +809,28 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
|||||||
color: var(--subtitle-name-match-color, #f5bde6);
|
color: var(--subtitle-name-match-color, #f5bde6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#subtitleRoot .word.word-character-image-token {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.08em;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#subtitleRoot .word-character-image {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 0.9em;
|
||||||
|
height: 0.9em;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: translateY(calc(-50% + 0.05em));
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 0.06em rgba(255, 255, 255, 0.32),
|
||||||
|
0 0.08em 0.2em rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
#subtitleRoot .word.word-jlpt-n1 {
|
#subtitleRoot .word.word-jlpt-n1 {
|
||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
|
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
|
||||||
@@ -1551,6 +1573,27 @@ iframe[id^='yomitan-popup'],
|
|||||||
color: var(--ctp-subtext1);
|
color: var(--ctp-subtext1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-dictionary-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-dictionary-search-input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
border: 1px solid rgba(110, 115, 141, 0.28);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(24, 25, 38, 0.88);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-dictionary-search-input:focus {
|
||||||
|
border-color: rgba(138, 173, 244, 0.75);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.character-dictionary-candidates {
|
.character-dictionary-candidates {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1602,6 +1645,11 @@ iframe[id^='yomitan-popup'],
|
|||||||
background: rgba(91, 96, 120, 0.9);
|
background: rgba(91, 96, 120, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.character-dictionary-use:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
.character-dictionary-empty {
|
.character-dictionary-empty {
|
||||||
color: var(--ctp-overlay1);
|
color: var(--ctp-overlay1);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -259,6 +259,103 @@ test('applySubtitleStyle sets subtitle name-match color variable', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renderSubtitle injects circular character image for annotated name matches', () => {
|
||||||
|
const restoreDocument = installFakeDocument();
|
||||||
|
try {
|
||||||
|
const subtitleRoot = new FakeElement('div');
|
||||||
|
const ctx = {
|
||||||
|
state: {
|
||||||
|
...createRendererState(),
|
||||||
|
nameMatchEnabled: true,
|
||||||
|
},
|
||||||
|
dom: {
|
||||||
|
subtitleRoot,
|
||||||
|
subtitleContainer: new FakeElement('div'),
|
||||||
|
secondarySubRoot: new FakeElement('div'),
|
||||||
|
secondarySubContainer: new FakeElement('div'),
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const renderer = createSubtitleRenderer(ctx);
|
||||||
|
renderer.renderSubtitle({
|
||||||
|
text: 'アクア',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
|
||||||
|
isNameMatch: true,
|
||||||
|
characterImage: {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アクア',
|
||||||
|
},
|
||||||
|
} as MergedToken,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [word] = collectWordNodes(subtitleRoot);
|
||||||
|
assert.ok(word);
|
||||||
|
assert.equal(word.className, 'word word-name-match word-character-image-token');
|
||||||
|
assert.equal(word.textContent, 'アクア');
|
||||||
|
const image = word.childNodes[0] as FakeElement & { src?: string; alt?: string };
|
||||||
|
assert.equal(image.tagName, 'img');
|
||||||
|
assert.equal(image.className, 'word-character-image');
|
||||||
|
assert.equal(image.src, 'data:image/png;base64,AAAA');
|
||||||
|
assert.equal(image.alt, 'アクア');
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSubtitle skips character image when name-match rendering is disabled', () => {
|
||||||
|
const restoreDocument = installFakeDocument();
|
||||||
|
try {
|
||||||
|
const subtitleRoot = new FakeElement('div');
|
||||||
|
const ctx = {
|
||||||
|
state: {
|
||||||
|
...createRendererState(),
|
||||||
|
nameMatchEnabled: false,
|
||||||
|
},
|
||||||
|
dom: {
|
||||||
|
subtitleRoot,
|
||||||
|
subtitleContainer: new FakeElement('div'),
|
||||||
|
secondarySubRoot: new FakeElement('div'),
|
||||||
|
secondarySubContainer: new FakeElement('div'),
|
||||||
|
},
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const renderer = createSubtitleRenderer(ctx);
|
||||||
|
renderer.renderSubtitle({
|
||||||
|
text: 'アクア',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
|
||||||
|
isNameMatch: true,
|
||||||
|
characterImage: {
|
||||||
|
src: 'data:image/png;base64,AAAA',
|
||||||
|
alt: 'アクア',
|
||||||
|
},
|
||||||
|
} as MergedToken,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [word] = collectWordNodes(subtitleRoot);
|
||||||
|
assert.ok(word);
|
||||||
|
assert.equal(word.className, 'word');
|
||||||
|
assert.equal(word.textContent, 'アクア');
|
||||||
|
assert.equal(word.childNodes.length, 0);
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer content security policy allows data URL character images', () => {
|
||||||
|
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
|
||||||
|
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
|
||||||
|
const cspMatch = htmlText.match(/http-equiv="Content-Security-Policy"[\s\S]*?content="([^"]+)"/);
|
||||||
|
|
||||||
|
assert.ok(cspMatch, 'renderer CSP meta tag should exist');
|
||||||
|
assert.match(cspMatch[1] ?? '', /(?:^|;)\s*img-src\s+[^;]*\bdata:/);
|
||||||
|
});
|
||||||
|
|
||||||
test('applySubtitleStyle stores secondary background styles in hover-aware css variables', () => {
|
test('applySubtitleStyle stores secondary background styles in hover-aware css variables', () => {
|
||||||
const restoreDocument = installFakeDocument();
|
const restoreDocument = installFakeDocument();
|
||||||
try {
|
try {
|
||||||
@@ -869,6 +966,19 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
|||||||
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
|
const wordBlock = extractClassBlock(cssText, '#subtitleRoot .word');
|
||||||
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
assert.match(wordBlock, /-webkit-text-fill-color:\s*currentColor\s*!important;/);
|
||||||
|
|
||||||
|
const characterImageTokenBlock = extractClassBlock(
|
||||||
|
cssText,
|
||||||
|
'#subtitleRoot .word.word-character-image-token',
|
||||||
|
);
|
||||||
|
assert.match(characterImageTokenBlock, /display:\s*inline-block;/);
|
||||||
|
assert.match(characterImageTokenBlock, /position:\s*relative;/);
|
||||||
|
assert.match(characterImageTokenBlock, /padding-left:\s*1\.08em;/);
|
||||||
|
|
||||||
|
const characterImageBlock = extractClassBlock(cssText, '#subtitleRoot .word-character-image');
|
||||||
|
assert.match(characterImageBlock, /position:\s*absolute;/);
|
||||||
|
assert.match(characterImageBlock, /top:\s*50%;/);
|
||||||
|
assert.match(characterImageBlock, /transform:\s*translateY\(calc\(-50%\s*\+\s*0\.05em\)\);/);
|
||||||
|
|
||||||
const frequencyTooltipBaseBlock = extractClassBlock(
|
const frequencyTooltipBaseBlock = extractClassBlock(
|
||||||
cssText,
|
cssText,
|
||||||
'#subtitleRoot .word[data-frequency-rank]::before',
|
'#subtitleRoot .word[data-frequency-rank]::before',
|
||||||
|
|||||||
@@ -105,6 +105,40 @@ function hasPrioritizedNameMatch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTokenCharacterImage(token: MergedToken): boolean {
|
||||||
|
return (
|
||||||
|
typeof token.characterImage?.src === 'string' && token.characterImage.src.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRenderTokenCharacterImage(
|
||||||
|
token: MergedToken,
|
||||||
|
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||||
|
): boolean {
|
||||||
|
return hasPrioritizedNameMatch(token, tokenRenderSettings) && hasTokenCharacterImage(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTokenSurface(
|
||||||
|
span: HTMLSpanElement,
|
||||||
|
token: MergedToken,
|
||||||
|
surface: string,
|
||||||
|
tokenRenderSettings: Partial<Pick<TokenRenderSettings, 'nameMatchEnabled'>>,
|
||||||
|
): void {
|
||||||
|
if (!shouldRenderTokenCharacterImage(token, tokenRenderSettings)) {
|
||||||
|
span.textContent = surface;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'word-character-image';
|
||||||
|
image.src = token.characterImage!.src;
|
||||||
|
image.alt = token.characterImage!.alt || token.headword || surface;
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.loading = 'eager';
|
||||||
|
span.appendChild(image);
|
||||||
|
span.appendChild(document.createTextNode(surface));
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
function sanitizeFrequencyTopX(value: unknown, fallback: number): number {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -393,7 +427,7 @@ function renderWithTokens(
|
|||||||
const token = segment.token;
|
const token = segment.token;
|
||||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||||
span.textContent = token.surface;
|
appendTokenSurface(span, token, token.surface, resolvedTokenRenderSettings);
|
||||||
span.dataset.tokenIndex = String(segment.tokenIndex);
|
span.dataset.tokenIndex = String(segment.tokenIndex);
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
@@ -429,7 +463,7 @@ function renderWithTokens(
|
|||||||
|
|
||||||
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement;
|
||||||
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
span.className = computeWordClass(token, resolvedTokenRenderSettings);
|
||||||
span.textContent = surface;
|
appendTokenSurface(span, token, surface, resolvedTokenRenderSettings);
|
||||||
span.dataset.tokenIndex = String(index);
|
span.dataset.tokenIndex = String(index);
|
||||||
if (token.reading) span.dataset.reading = token.reading;
|
if (token.reading) span.dataset.reading = token.reading;
|
||||||
if (token.headword) span.dataset.headword = token.headword;
|
if (token.headword) span.dataset.headword = token.headword;
|
||||||
@@ -572,6 +606,10 @@ export function computeWordClass(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldRenderTokenCharacterImage(token, resolvedTokenRenderSettings)) {
|
||||||
|
classes.push('word-character-image-token');
|
||||||
|
}
|
||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export type RendererDom = {
|
|||||||
characterDictionaryModal: HTMLDivElement;
|
characterDictionaryModal: HTMLDivElement;
|
||||||
characterDictionaryClose: HTMLButtonElement;
|
characterDictionaryClose: HTMLButtonElement;
|
||||||
characterDictionarySummary: HTMLDivElement;
|
characterDictionarySummary: HTMLDivElement;
|
||||||
|
characterDictionarySearchInput: HTMLInputElement;
|
||||||
|
characterDictionarySearchButton: HTMLButtonElement;
|
||||||
characterDictionaryCurrent: HTMLDivElement;
|
characterDictionaryCurrent: HTMLDivElement;
|
||||||
characterDictionaryCandidates: HTMLUListElement;
|
characterDictionaryCandidates: HTMLUListElement;
|
||||||
characterDictionaryStatus: HTMLDivElement;
|
characterDictionaryStatus: HTMLDivElement;
|
||||||
@@ -187,6 +189,12 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
characterDictionaryModal: getRequiredElement<HTMLDivElement>('characterDictionaryModal'),
|
||||||
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
characterDictionaryClose: getRequiredElement<HTMLButtonElement>('characterDictionaryClose'),
|
||||||
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
characterDictionarySummary: getRequiredElement<HTMLDivElement>('characterDictionarySummary'),
|
||||||
|
characterDictionarySearchInput: getRequiredElement<HTMLInputElement>(
|
||||||
|
'characterDictionarySearchInput',
|
||||||
|
),
|
||||||
|
characterDictionarySearchButton: getRequiredElement<HTMLButtonElement>(
|
||||||
|
'characterDictionarySearchButton',
|
||||||
|
),
|
||||||
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
|
characterDictionaryCurrent: getRequiredElement<HTMLDivElement>('characterDictionaryCurrent'),
|
||||||
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
characterDictionaryCandidates: getRequiredElement<HTMLUListElement>(
|
||||||
'characterDictionaryCandidates',
|
'characterDictionaryCandidates',
|
||||||
|
|||||||
@@ -474,7 +474,9 @@ export interface ElectronAPI {
|
|||||||
youtubePickerResolve: (
|
youtubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
getCharacterDictionarySelection: () => Promise<CharacterDictionarySelectionSnapshot>;
|
getCharacterDictionarySelection: (
|
||||||
|
searchTitle?: string,
|
||||||
|
) => Promise<CharacterDictionarySelectionSnapshot>;
|
||||||
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
|
setCharacterDictionarySelection: (mediaId: number) => Promise<CharacterDictionarySelectionResult>;
|
||||||
notifyOverlayModalClosed: (
|
notifyOverlayModalClosed: (
|
||||||
modal:
|
modal:
|
||||||
|
|||||||
@@ -39,10 +39,16 @@ export interface MergedToken {
|
|||||||
isKnown: boolean;
|
isKnown: boolean;
|
||||||
isNPlusOneTarget: boolean;
|
isNPlusOneTarget: boolean;
|
||||||
isNameMatch?: boolean;
|
isNameMatch?: boolean;
|
||||||
|
characterImage?: CharacterNameImage;
|
||||||
jlptLevel?: JlptLevel;
|
jlptLevel?: JlptLevel;
|
||||||
frequencyRank?: number;
|
frequencyRank?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterNameImage {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type FrequencyDictionaryLookup = (term: string) => number | null;
|
export type FrequencyDictionaryLookup = (term: string) => number | null;
|
||||||
|
|
||||||
export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5';
|
export type JlptLevel = 'N1' | 'N2' | 'N3' | 'N4' | 'N5';
|
||||||
@@ -78,6 +84,7 @@ export interface SubtitleStyleConfig {
|
|||||||
hoverTokenColor?: string;
|
hoverTokenColor?: string;
|
||||||
hoverTokenBackgroundColor?: string;
|
hoverTokenBackgroundColor?: string;
|
||||||
nameMatchEnabled?: boolean;
|
nameMatchEnabled?: boolean;
|
||||||
|
nameMatchImagesEnabled?: boolean;
|
||||||
nameMatchColor?: string;
|
nameMatchColor?: string;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user