mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e0e9c182ee
|
|||
|
3c3bf3bb18
|
|||
|
99401e5a70
|
|||
|
01c2d9eb1d
|
|||
|
2fca5bbdba
|
|||
|
83897c81d1
|
|||
|
e18ccfe288
|
|||
|
10c99b526b
|
|||
|
eb5e07cfc0
|
|||
|
e9d97fb01e
|
|||
|
0174014b35
|
|||
|
077aa89eb8
|
|||
|
b343f1cf5c
|
@@ -0,0 +1,8 @@
|
|||||||
|
type: changed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||||
|
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
|
||||||
|
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||||
|
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
|
||||||
|
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
|
||||||
@@ -496,7 +496,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"SubMiner"
|
"SubMiner"
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
|
|||||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||||
|
|
||||||
::: tip New to these terms?
|
::: tip New to these terms?
|
||||||
|
|
||||||
- **Anki** is the flashcard app where your study cards live.
|
- **Anki** is the flashcard app where your study cards live.
|
||||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||||
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
|
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
|
||||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
|||||||
|
|
||||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
||||||
|
|
||||||
**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||||
|
|
||||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
|
|||||||
4. Fills the translation field from the secondary subtitle or AI.
|
4. Fills the translation field from the secondary subtitle or AI.
|
||||||
5. Writes metadata to the miscInfo field.
|
5. Writes metadata to the miscInfo field.
|
||||||
|
|
||||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it uses Yomitan's current mining deck when available; otherwise it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
||||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||||
|
|
||||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
|
|||||||
- Tracking & App
|
- Tracking & App
|
||||||
- Advanced
|
- Advanced
|
||||||
|
|
||||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||||
|
|
||||||
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
||||||
|
|
||||||
@@ -517,8 +517,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
@@ -944,7 +944,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
@@ -953,7 +953,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
|
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||||
@@ -1122,6 +1122,8 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
|||||||
| `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`. |
|
||||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||||
|
|
||||||
|
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
|
|
||||||
@@ -1433,7 +1435,7 @@ Usage notes:
|
|||||||
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
|
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
|
||||||
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
|
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
|
||||||
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
||||||
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
|
- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
|
||||||
|
|
||||||
### MPV Launcher
|
### MPV Launcher
|
||||||
|
|
||||||
@@ -1457,14 +1459,14 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||||
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
|
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
|
||||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
|||||||
{
|
{
|
||||||
"immersionTracking": {
|
"immersionTracking": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"dbPath": ""
|
"dbPath": "",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,6 +70,10 @@ Top repeated words (click a bar to open the word), new-word timeline, frequency
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### Search
|
||||||
|
|
||||||
|
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
|
||||||
|
|
||||||
Stats server config lives under `stats`:
|
Stats server config lives under `stats`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
@@ -78,8 +82,8 @@ Stats server config lives under `stats`:
|
|||||||
"toggleKey": "Backquote",
|
"toggleKey": "Backquote",
|
||||||
"serverPort": 6969,
|
"serverPort": 6969,
|
||||||
"autoStartServer": true,
|
"autoStartServer": true,
|
||||||
"autoOpenBrowser": false
|
"autoOpenBrowser": false,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,15 +100,15 @@ Stats server config lives under `stats`:
|
|||||||
|
|
||||||
## Mining Cards from the Stats Page
|
## Mining Cards from the Stats Page
|
||||||
|
|
||||||
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons:
|
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||||
|
|
||||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available.
|
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||||
|
|
||||||
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
||||||
|
|
||||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page.
|
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
|
||||||
|
|
||||||
### Word Exclusion List
|
### Word Exclusion List
|
||||||
|
|
||||||
@@ -115,7 +119,7 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
|
|||||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||||
|
|
||||||
| Data type | Retention |
|
| Data type | Retention |
|
||||||
| -------------- | --------- |
|
| --------------- | ------------ |
|
||||||
| Raw events | 0 (keep all) |
|
| Raw events | 0 (keep all) |
|
||||||
| Telemetry | 0 (keep all) |
|
| Telemetry | 0 (keep all) |
|
||||||
| Sessions | 0 (keep all) |
|
| Sessions | 0 (keep all) |
|
||||||
@@ -147,7 +151,7 @@ The tracker is optimized for "keep everything" defaults:
|
|||||||
All policy options live under `immersionTracking` in your config:
|
All policy options live under `immersionTracking` in your config:
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
| ------ | ----------- |
|
| ------------------------------ | ------------------------------------------------------------------ |
|
||||||
| `batchSize` | Writes per flush batch |
|
| `batchSize` | Writes per flush batch |
|
||||||
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
||||||
| `queueCap` | Max queued writes before oldest are dropped |
|
| `queueCap` | Max queued writes before oldest are dropped |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||||
|
|
||||||
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
|
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`)
|
|||||||
Cycle each bar's mode at runtime with its own shortcut:
|
Cycle each bar's mode at runtime with its own shortcut:
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| -------------------- | -------------------------------------------------------- | ------------------------------ |
|
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
||||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||||
|
|
||||||
@@ -166,6 +166,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
|
|
||||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||||
|
|
||||||
|
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||||
|
|
||||||
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||||
|
|
||||||
## Texthooker
|
## Texthooker
|
||||||
|
|||||||
@@ -496,7 +496,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"SubMiner"
|
"SubMiner"
|
||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
|
|||||||
+1
-1
@@ -287,7 +287,7 @@ Notes:
|
|||||||
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
||||||
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
||||||
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
|
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
|
||||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||||
|
|
||||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Yomitan**: Updated the bundled Yomitan to the latest revision.
|
|
||||||
- Picks up the newest lookup improvements and fixes from the SubMiner Yomitan fork.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Anki / Animated AVIF**: Clips with word audio no longer start or end early.
|
|
||||||
- Clip boundaries are now snapped to the nearest AVIF frame edge, keeping audio lead-in and playback in sync.
|
|
||||||
|
|
||||||
- **macOS Overlay**: Resolved several interactivity and focus issues triggered by autoplay and modal windows.
|
|
||||||
- After autoplay starts with "wait for overlay to be ready" enabled, subtitles are immediately hoverable and Yomitan lookups work - no longer require an extra click to activate.
|
|
||||||
- After any modal closes (Settings, Stats, sidebar, etc.), the overlay and subtitles reappear automatically and mpv keyboard shortcuts (pause, seek, etc.) are restored to mpv right away, including in native fullscreen.
|
|
||||||
|
|
||||||
- **Hyprland Fullscreen Overlay**: Fixed overlay alignment when mpv is fullscreen on Hyprland.
|
|
||||||
- Compositor client bounds are now verified before positioning, so the stats panel, modals, and subtitle sidebar no longer shift below the mpv window.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
|
|||||||
return (result as number[]) || [];
|
return (result as number[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findCards(query: string, options?: { maxRetries?: number }): Promise<number[]> {
|
||||||
|
const result = await this.invoke('findCards', { query }, options);
|
||||||
|
return (result as number[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeDeck(cardIds: number[], deckName: string): Promise<void> {
|
||||||
|
if (cardIds.length === 0 || !deckName.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invoke('changeDeck', {
|
||||||
|
cards: cardIds,
|
||||||
|
deck: deckName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deckNames(): Promise<string[]> {
|
async deckNames(): Promise<string[]> {
|
||||||
const result = await this.invoke('deckNames');
|
const result = await this.invoke('deckNames');
|
||||||
return Array.isArray(result)
|
return Array.isArray(result)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.ankiConnect.deck,
|
defaultValue: defaultConfig.ankiConnect.deck,
|
||||||
description:
|
description:
|
||||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
|
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.fields.word',
|
path: 'ankiConnect.fields.word',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1164,6 +1164,54 @@ test('recordSubtitleLine leaves session token counts at zero when tokenization i
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('recordSubtitleLine skips invalid cue timing and still stores the later valid cue', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
|
tracker.handleMediaChange('/tmp/timing.mkv', 'Timing');
|
||||||
|
tracker.recordSubtitleLine('same subtitle', 953.991, 953.891);
|
||||||
|
tracker.recordSubtitleLine('same subtitle', 953.991, 956.56);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
flushTelemetry: (force?: boolean) => void;
|
||||||
|
flushNow: () => void;
|
||||||
|
};
|
||||||
|
privateApi.flushTelemetry(true);
|
||||||
|
privateApi.flushNow();
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT line_index, segment_start_ms, segment_end_ms, text
|
||||||
|
FROM imm_subtitle_lines
|
||||||
|
ORDER BY line_id ASC`,
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
|
line_index: number;
|
||||||
|
segment_start_ms: number | null;
|
||||||
|
segment_end_ms: number | null;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
assert.deepEqual(rows, [
|
||||||
|
{
|
||||||
|
line_index: 1,
|
||||||
|
segment_start_ms: 953991,
|
||||||
|
segment_end_ms: 956560,
|
||||||
|
text: 'same subtitle',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
getStatsExcludedWords,
|
getStatsExcludedWords,
|
||||||
getVocabularyStats,
|
getVocabularyStats,
|
||||||
replaceStatsExcludedWords,
|
replaceStatsExcludedWords,
|
||||||
|
searchSubtitleSentences,
|
||||||
getWordAnimeAppearances,
|
getWordAnimeAppearances,
|
||||||
getWordDetail,
|
getWordDetail,
|
||||||
getWordOccurrences,
|
getWordOccurrences,
|
||||||
@@ -148,6 +149,8 @@ import {
|
|||||||
type MediaLibraryRow,
|
type MediaLibraryRow,
|
||||||
type NewAnimePerDayRow,
|
type NewAnimePerDayRow,
|
||||||
type QueuedWrite,
|
type QueuedWrite,
|
||||||
|
type SentenceSearchOptions,
|
||||||
|
type SentenceSearchResultRow,
|
||||||
type SessionEventRow,
|
type SessionEventRow,
|
||||||
type SessionState,
|
type SessionState,
|
||||||
type SessionSummaryQueryRow,
|
type SessionSummaryQueryRow,
|
||||||
@@ -568,6 +571,14 @@ export class ImmersionTrackerService {
|
|||||||
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchSubtitleSentences(
|
||||||
|
query: string,
|
||||||
|
limit = 50,
|
||||||
|
options?: SentenceSearchOptions,
|
||||||
|
): Promise<SentenceSearchResultRow[]> {
|
||||||
|
return searchSubtitleSentences(this.db, query, limit, options);
|
||||||
|
}
|
||||||
|
|
||||||
async getSessionEvents(
|
async getSessionEvents(
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
limit = 500,
|
limit = 500,
|
||||||
@@ -1294,7 +1305,7 @@ export class ImmersionTrackerService {
|
|||||||
const cleaned = normalizeText(text);
|
const cleaned = normalizeText(text);
|
||||||
if (!cleaned) return;
|
if (!cleaned) return;
|
||||||
|
|
||||||
if (!endSec || endSec <= 0) {
|
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const start = source.indexOf('export function getRollupGroupsForSessions');
|
||||||
|
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
|
||||||
|
const functionSource = source.slice(start, end);
|
||||||
|
|
||||||
|
assert.match(functionSource, /'unixepoch', 'localtime'/);
|
||||||
|
assert.doesNotMatch(functionSource, /UNION/);
|
||||||
|
assert.doesNotMatch(functionSource, /86400000/);
|
||||||
|
});
|
||||||
@@ -356,6 +356,81 @@ test('split session and lexical helpers return distinct-headword, detail, appear
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('similar words use same reading and shared kanji without kana suffix noise', () => {
|
||||||
|
const { db, dbPath, stmts } = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Similar Words Anime',
|
||||||
|
canonicalTitle: 'Similar Words Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/similar-words.mkv', {
|
||||||
|
canonicalTitle: 'Similar Words Episode',
|
||||||
|
sourcePath: '/tmp/similar-words.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const sessionId = startSessionRecord(db, videoId, 1_000_000).sessionId;
|
||||||
|
|
||||||
|
const araiId = insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: 1,
|
||||||
|
text: '荒い息',
|
||||||
|
word: { headword: '荒い', word: '荒い', reading: 'あらい' },
|
||||||
|
});
|
||||||
|
insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: 2,
|
||||||
|
text: '洗い物',
|
||||||
|
word: { headword: '洗い', word: '洗い', reading: 'あらい' },
|
||||||
|
});
|
||||||
|
insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: 3,
|
||||||
|
text: '荒波',
|
||||||
|
word: { headword: '荒波', word: '荒波', reading: 'あらなみ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let lineIndex = 4; lineIndex < 9; lineIndex++) {
|
||||||
|
insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex,
|
||||||
|
text: '良い',
|
||||||
|
word: { headword: '良い', word: '良い', reading: 'よい' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
insertWordOccurrence(db, stmts, {
|
||||||
|
sessionId,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
lineIndex: 9,
|
||||||
|
text: 'お構いなく',
|
||||||
|
word: { headword: 'お構いなく', word: 'お構いなく', reading: 'おかまいなく' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getSimilarWords(db, araiId, 10).map((row) => row.headword),
|
||||||
|
['洗い', '荒波'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('split library helpers return anime/media session and analytics rows', () => {
|
test('split library helpers return anime/media session and analytics rows', () => {
|
||||||
const { db, dbPath, stmts } = createDb();
|
const { db, dbPath, stmts } = createDb();
|
||||||
|
|
||||||
@@ -605,6 +680,79 @@ test('split maintenance helpers update anime metadata and watched state', () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
|
||||||
|
const { db, dbPath } = createDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
|
||||||
|
canonicalTitle: 'Rollup Keep',
|
||||||
|
sourcePath: '/tmp/rollup-keep.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
|
||||||
|
canonicalTitle: 'Rollup Drop',
|
||||||
|
sourcePath: '/tmp/rollup-drop.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keepStartedAtMs = 1_700_000_000_000;
|
||||||
|
const dropStartedAtMs = 1_700_086_400_000;
|
||||||
|
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
|
||||||
|
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
|
||||||
|
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
|
||||||
|
activeWatchedMs: 30_000,
|
||||||
|
cardsMined: 1,
|
||||||
|
});
|
||||||
|
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
|
||||||
|
activeWatchedMs: 60_000,
|
||||||
|
cardsMined: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
|
||||||
|
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
|
||||||
|
const keepMonth = 202311;
|
||||||
|
const dropMonth = 202311;
|
||||||
|
|
||||||
|
const insertDaily = db.prepare(`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const insertMonthly = db.prepare(`
|
||||||
|
INSERT INTO imm_monthly_rollups (
|
||||||
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||||
|
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||||
|
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||||
|
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||||
|
|
||||||
|
deleteSessions(db, [dropSessionId]);
|
||||||
|
|
||||||
|
const dailyRows = db
|
||||||
|
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
|
||||||
|
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
|
||||||
|
const monthlyRows = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
|
||||||
|
)
|
||||||
|
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
|
||||||
|
|
||||||
|
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
|
||||||
|
assert.deepEqual(monthlyRows, [
|
||||||
|
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
|
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
|
||||||
const { db, dbPath, stmts } = createDb();
|
const { db, dbPath, stmts } = createDb();
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ import {
|
|||||||
getSessionTimeline,
|
getSessionTimeline,
|
||||||
getSessionWordsByLine,
|
getSessionWordsByLine,
|
||||||
getWordOccurrences,
|
getWordOccurrences,
|
||||||
|
searchSubtitleSentences,
|
||||||
upsertCoverArt,
|
upsertCoverArt,
|
||||||
} from '../query.js';
|
} from '../query.js';
|
||||||
import {
|
import {
|
||||||
|
getLocalEpochDay,
|
||||||
getShiftedLocalDaySec,
|
getShiftedLocalDaySec,
|
||||||
getStartOfLocalDayTimestamp,
|
getStartOfLocalDayTimestamp,
|
||||||
toDbTimestamp,
|
toDbTimestamp,
|
||||||
@@ -3686,6 +3688,187 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Dungeon Meshi',
|
||||||
|
canonicalTitle: 'Dungeon Meshi',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: '{"source":"test"}',
|
||||||
|
});
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
|
||||||
|
canonicalTitle: 'Episode 1',
|
||||||
|
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'Dungeon Meshi 01.mkv',
|
||||||
|
parsedTitle: 'Dungeon Meshi',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: '{"episode":1}',
|
||||||
|
});
|
||||||
|
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_subtitle_lines (
|
||||||
|
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||||
|
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
sessionId,
|
||||||
|
null,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
7,
|
||||||
|
4_000,
|
||||||
|
5_500,
|
||||||
|
'魔物を食べるなんて信じられない',
|
||||||
|
'I cannot believe we are eating monsters',
|
||||||
|
3_000,
|
||||||
|
3_000,
|
||||||
|
);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_subtitle_lines (
|
||||||
|
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||||
|
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
sessionId,
|
||||||
|
null,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
8,
|
||||||
|
6_000,
|
||||||
|
7_000,
|
||||||
|
'これは別の行です',
|
||||||
|
'Another line',
|
||||||
|
2_000,
|
||||||
|
2_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
|
||||||
|
|
||||||
|
assert.deepEqual(rows, [
|
||||||
|
{
|
||||||
|
animeId,
|
||||||
|
animeTitle: 'Dungeon Meshi',
|
||||||
|
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||||
|
secondaryText: 'I cannot believe we are eating monsters',
|
||||||
|
videoId,
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
sessionId,
|
||||||
|
lineIndex: 7,
|
||||||
|
segmentStartMs: 4_000,
|
||||||
|
segmentEndMs: 5_500,
|
||||||
|
text: '魔物を食べるなんて信じられない',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
|
||||||
|
assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY));
|
||||||
|
assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Little Witch Academia',
|
||||||
|
canonicalTitle: 'Little Witch Academia',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: '{"source":"test"}',
|
||||||
|
});
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-05.mkv', {
|
||||||
|
canonicalTitle: 'Episode 5',
|
||||||
|
sourcePath: '/tmp/Little Witch Academia S01E05.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'Little Witch Academia S01E05.mkv',
|
||||||
|
parsedTitle: 'Little Witch Academia',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 5,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: '{"episode":5}',
|
||||||
|
});
|
||||||
|
const { sessionId } = startSessionRecord(db, videoId, 4_000_000);
|
||||||
|
const lineResult = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO imm_subtitle_lines (
|
||||||
|
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||||
|
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
sessionId,
|
||||||
|
null,
|
||||||
|
videoId,
|
||||||
|
animeId,
|
||||||
|
20,
|
||||||
|
247_000,
|
||||||
|
250_000,
|
||||||
|
'ああ、名無しが何だか知らねえが',
|
||||||
|
null,
|
||||||
|
4_000,
|
||||||
|
4_000,
|
||||||
|
);
|
||||||
|
const wordResult = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO imm_words (
|
||||||
|
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run('知る', '知らねえ', 'しらねえ', 'verb', '動詞', '自立', '', 4_000, 4_000, 1);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
|
||||||
|
|
||||||
|
assert.deepEqual(searchSubtitleSentences(db, '知らない', 10), []);
|
||||||
|
|
||||||
|
const rows = searchSubtitleSentences(db, '知らない', 10, {
|
||||||
|
headwordTerms: [{ term: '知らない', headwords: ['知る'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.text),
|
||||||
|
['ああ、名無しが何だか知らねえが'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
searchSubtitleSentences(db, '知らねえ', 10).map((row) => row.text),
|
||||||
|
['ああ、名無しが何だか知らねえが'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
@@ -4100,8 +4283,14 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
|
|
||||||
const startedAtMs = 9_000_000;
|
const startedAtMs = 9_000_000;
|
||||||
const endedAtMs = startedAtMs + 120_000;
|
const endedAtMs = startedAtMs + 120_000;
|
||||||
const rollupDay = Math.floor(startedAtMs / 86_400_000);
|
const rollupDay = getLocalEpochDay(db, startedAtMs);
|
||||||
const rollupMonth = 197001;
|
const rollupMonth = (
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT CAST(strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollupMonth",
|
||||||
|
)
|
||||||
|
.get(startedAtMs) as { rollupMonth: number }
|
||||||
|
).rollupMonth;
|
||||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
|
|||||||
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
|
|||||||
mediaBufferEvents: number;
|
mediaBufferEvents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RETAINED_SESSION_METRICS_CTE = `
|
||||||
|
retained_sessions AS (
|
||||||
|
SELECT
|
||||||
|
s.session_id,
|
||||||
|
s.video_id,
|
||||||
|
v.anime_id,
|
||||||
|
s.started_at_ms,
|
||||||
|
s.ended_at_ms,
|
||||||
|
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
|
||||||
|
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
|
||||||
|
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
|
||||||
|
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS tokens_seen,
|
||||||
|
CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed
|
||||||
|
FROM imm_sessions s
|
||||||
|
JOIN imm_videos v
|
||||||
|
ON v.video_id = s.video_id
|
||||||
|
LEFT JOIN imm_session_telemetry t
|
||||||
|
ON t.telemetry_id = (
|
||||||
|
SELECT telemetry_id
|
||||||
|
FROM imm_session_telemetry
|
||||||
|
WHERE session_id = s.session_id
|
||||||
|
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE s.ended_at_ms IS NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
function hasRetainedPriorSession(
|
function hasRetainedPriorSession(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoId: number,
|
videoId: number,
|
||||||
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
rebuiltAtMs: number,
|
rebuiltAtMs: number,
|
||||||
): LifetimeRebuildSummary {
|
): LifetimeRebuildSummary {
|
||||||
const rows = db
|
const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
|
||||||
.prepare(
|
const appliedSessions = Number(
|
||||||
`
|
(
|
||||||
SELECT
|
db
|
||||||
session_id AS sessionId,
|
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
|
||||||
video_id AS videoId,
|
.get() as { total: number }
|
||||||
started_at_ms AS startedAtMs,
|
).total,
|
||||||
ended_at_ms AS endedAtMs,
|
);
|
||||||
ended_media_ms AS lastMediaMs,
|
|
||||||
total_watched_ms AS totalWatchedMs,
|
|
||||||
active_watched_ms AS activeWatchedMs,
|
|
||||||
lines_seen AS linesSeen,
|
|
||||||
tokens_seen AS tokensSeen,
|
|
||||||
cards_mined AS cardsMined,
|
|
||||||
lookup_count AS lookupCount,
|
|
||||||
lookup_hits AS lookupHits,
|
|
||||||
yomitan_lookup_count AS yomitanLookupCount,
|
|
||||||
pause_count AS pauseCount,
|
|
||||||
pause_ms AS pauseMs,
|
|
||||||
seek_forward_count AS seekForwardCount,
|
|
||||||
seek_backward_count AS seekBackwardCount,
|
|
||||||
media_buffer_events AS mediaBufferEvents
|
|
||||||
FROM imm_sessions
|
|
||||||
WHERE ended_at_ms IS NOT NULL
|
|
||||||
ORDER BY started_at_ms ASC, session_id ASC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all() as Array<
|
|
||||||
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
|
|
||||||
startedAtMs: number | string;
|
|
||||||
endedAtMs: number | string;
|
|
||||||
lastMediaMs: number | string | null;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
const sessions = rows.map((row) => ({
|
|
||||||
...row,
|
|
||||||
startedAtMs: row.startedAtMs,
|
|
||||||
endedAtMs: row.endedAtMs,
|
|
||||||
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
|
|
||||||
})) as RetainedSessionRow[];
|
|
||||||
|
|
||||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||||
for (const session of sessions) {
|
|
||||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
db.prepare(
|
||||||
}
|
`
|
||||||
|
INSERT INTO imm_lifetime_applied_sessions (
|
||||||
|
session_id,
|
||||||
|
applied_at_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
ended_at_ms,
|
||||||
|
?,
|
||||||
|
?
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE ended_at_ms IS NOT NULL
|
||||||
|
`,
|
||||||
|
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||||
|
INSERT INTO imm_lifetime_media (
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
video_id,
|
||||||
|
COUNT(*) AS total_sessions,
|
||||||
|
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||||
|
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||||
|
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||||
|
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||||
|
MAX(completed) AS completed,
|
||||||
|
MIN(started_at_ms) AS first_watched_ms,
|
||||||
|
MAX(ended_at_ms) AS last_watched_ms,
|
||||||
|
? AS CREATED_DATE,
|
||||||
|
? AS LAST_UPDATE_DATE
|
||||||
|
FROM retained_sessions
|
||||||
|
GROUP BY video_id
|
||||||
|
`,
|
||||||
|
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||||
|
INSERT INTO imm_lifetime_anime (
|
||||||
|
anime_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
episodes_started,
|
||||||
|
episodes_completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
anime_id,
|
||||||
|
COUNT(*) AS total_sessions,
|
||||||
|
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||||
|
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||||
|
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||||
|
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||||
|
COUNT(DISTINCT video_id) AS episodes_started,
|
||||||
|
COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed,
|
||||||
|
MIN(started_at_ms) AS first_watched_ms,
|
||||||
|
MAX(ended_at_ms) AS last_watched_ms,
|
||||||
|
? AS CREATED_DATE,
|
||||||
|
? AS LAST_UPDATE_DATE
|
||||||
|
FROM retained_sessions
|
||||||
|
WHERE anime_id IS NOT NULL
|
||||||
|
GROUP BY anime_id
|
||||||
|
`,
|
||||||
|
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
WITH ${RETAINED_SESSION_METRICS_CTE},
|
||||||
|
anime_completion AS (
|
||||||
|
SELECT
|
||||||
|
rs.anime_id,
|
||||||
|
MAX(a.episodes_total) AS episodes_total,
|
||||||
|
COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos
|
||||||
|
FROM retained_sessions rs
|
||||||
|
JOIN imm_anime a
|
||||||
|
ON a.anime_id = rs.anime_id
|
||||||
|
WHERE rs.anime_id IS NOT NULL
|
||||||
|
GROUP BY rs.anime_id
|
||||||
|
)
|
||||||
|
UPDATE imm_lifetime_global
|
||||||
|
SET
|
||||||
|
total_sessions = (SELECT COUNT(*) FROM retained_sessions),
|
||||||
|
total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions),
|
||||||
|
total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions),
|
||||||
|
active_days = (
|
||||||
|
SELECT COUNT(DISTINCT CAST(
|
||||||
|
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||||
|
AS INTEGER
|
||||||
|
))
|
||||||
|
FROM retained_sessions
|
||||||
|
),
|
||||||
|
episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions),
|
||||||
|
episodes_completed = (
|
||||||
|
SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END)
|
||||||
|
FROM retained_sessions
|
||||||
|
),
|
||||||
|
anime_completed = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM anime_completion
|
||||||
|
WHERE episodes_total IS NOT NULL
|
||||||
|
AND episodes_total > 0
|
||||||
|
AND completed_videos >= episodes_total
|
||||||
|
),
|
||||||
|
last_rebuilt_ms = ?,
|
||||||
|
LAST_UPDATE_DATE = ?
|
||||||
|
WHERE global_id = 1
|
||||||
|
`,
|
||||||
|
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appliedSessions: sessions.length,
|
appliedSessions,
|
||||||
rebuiltAtMs,
|
rebuiltAtMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||||
|
|
||||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||||
const DAILY_MS = 86_400_000;
|
const DAILY_MS = 86_400_000;
|
||||||
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
|
|||||||
maxSampleMs: number | null;
|
maxSampleMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RollupGroup {
|
||||||
|
rollupDay: number;
|
||||||
|
rollupMonth: number;
|
||||||
|
videoId: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawRetentionResult {
|
interface RawRetentionResult {
|
||||||
deletedSessionEvents: number;
|
deletedSessionEvents: number;
|
||||||
deletedTelemetryRows: number;
|
deletedTelemetryRows: number;
|
||||||
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertStmt = db.prepare(`
|
const upsertStmt = db.prepare(`
|
||||||
|
WITH matching_sessions AS (
|
||||||
|
SELECT *
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
||||||
|
AND video_id = ?
|
||||||
|
),
|
||||||
|
session_metrics AS (
|
||||||
|
SELECT
|
||||||
|
t.session_id,
|
||||||
|
MAX(t.active_watched_ms) AS max_active_ms,
|
||||||
|
MAX(t.lines_seen) AS max_lines,
|
||||||
|
MAX(t.tokens_seen) AS max_tokens,
|
||||||
|
MAX(t.cards_mined) AS max_cards,
|
||||||
|
MAX(t.lookup_count) AS max_lookups,
|
||||||
|
MAX(t.lookup_hits) AS max_hits
|
||||||
|
FROM imm_session_telemetry t
|
||||||
|
JOIN matching_sessions s
|
||||||
|
ON s.session_id = t.session_id
|
||||||
|
GROUP BY t.session_id
|
||||||
|
)
|
||||||
INSERT INTO imm_daily_rollups (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, cards_per_hour,
|
total_tokens_seen, total_cards, cards_per_hour,
|
||||||
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
|
|||||||
END AS lookup_hit_rate,
|
END AS lookup_hit_rate,
|
||||||
? AS CREATED_DATE,
|
? AS CREATED_DATE,
|
||||||
? AS LAST_UPDATE_DATE
|
? AS LAST_UPDATE_DATE
|
||||||
FROM imm_sessions s
|
FROM matching_sessions s
|
||||||
LEFT JOIN (
|
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||||
SELECT
|
|
||||||
t.session_id,
|
|
||||||
MAX(t.active_watched_ms) AS max_active_ms,
|
|
||||||
MAX(t.lines_seen) AS max_lines,
|
|
||||||
MAX(t.tokens_seen) AS max_tokens,
|
|
||||||
MAX(t.cards_mined) AS max_cards,
|
|
||||||
MAX(t.lookup_count) AS max_lookups,
|
|
||||||
MAX(t.lookup_hits) AS max_hits
|
|
||||||
FROM imm_session_telemetry t
|
|
||||||
GROUP BY t.session_id
|
|
||||||
) sm ON s.session_id = sm.session_id
|
|
||||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
|
|
||||||
GROUP BY rollup_day, s.video_id
|
GROUP BY rollup_day, s.video_id
|
||||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
for (const { rollupDay, videoId } of groups) {
|
for (const { rollupDay, videoId } of groups) {
|
||||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
|
upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertStmt = db.prepare(`
|
const upsertStmt = db.prepare(`
|
||||||
|
WITH matching_sessions AS (
|
||||||
|
SELECT *
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
|
||||||
|
AND video_id = ?
|
||||||
|
),
|
||||||
|
session_metrics AS (
|
||||||
|
SELECT
|
||||||
|
t.session_id,
|
||||||
|
MAX(t.active_watched_ms) AS max_active_ms,
|
||||||
|
MAX(t.lines_seen) AS max_lines,
|
||||||
|
MAX(t.tokens_seen) AS max_tokens,
|
||||||
|
MAX(t.cards_mined) AS max_cards
|
||||||
|
FROM imm_session_telemetry t
|
||||||
|
JOIN matching_sessions s
|
||||||
|
ON s.session_id = t.session_id
|
||||||
|
GROUP BY t.session_id
|
||||||
|
)
|
||||||
INSERT INTO imm_monthly_rollups (
|
INSERT INTO imm_monthly_rollups (
|
||||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||||
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||||
? AS CREATED_DATE,
|
? AS CREATED_DATE,
|
||||||
? AS LAST_UPDATE_DATE
|
? AS LAST_UPDATE_DATE
|
||||||
FROM imm_sessions s
|
FROM matching_sessions s
|
||||||
LEFT JOIN (
|
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||||
SELECT
|
|
||||||
t.session_id,
|
|
||||||
MAX(t.active_watched_ms) AS max_active_ms,
|
|
||||||
MAX(t.lines_seen) AS max_lines,
|
|
||||||
MAX(t.tokens_seen) AS max_tokens,
|
|
||||||
MAX(t.cards_mined) AS max_cards
|
|
||||||
FROM imm_session_telemetry t
|
|
||||||
GROUP BY t.session_id
|
|
||||||
) sm ON s.session_id = sm.session_id
|
|
||||||
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
|
|
||||||
GROUP BY rollup_month, s.video_id
|
GROUP BY rollup_month, s.video_id
|
||||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
@@ -278,10 +300,75 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
for (const { rollupMonth, videoId } of groups) {
|
for (const { rollupMonth, videoId } of groups) {
|
||||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
|
upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = makePlaceholders(sessionIds);
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
|
||||||
|
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
|
||||||
|
video_id
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE session_id IN (${placeholders})
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...sessionIds) as RollupGroupRow[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
rollupDay: row.rollup_day,
|
||||||
|
rollupMonth: row.rollup_month,
|
||||||
|
videoId: row.video_id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshRollupsForGroupsInTransaction(
|
||||||
|
db: DatabaseSync,
|
||||||
|
groups: RollupGroup[],
|
||||||
|
): void {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollupNowMs = toDbTimestamp(nowMs());
|
||||||
|
const dailyGroups = dedupeGroups(
|
||||||
|
groups.map((group) => ({
|
||||||
|
rollupDay: group.rollupDay,
|
||||||
|
videoId: group.videoId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const monthlyGroups = dedupeGroups(
|
||||||
|
groups.map((group) => ({
|
||||||
|
rollupMonth: group.rollupMonth,
|
||||||
|
videoId: group.videoId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const deleteDailyStmt = db.prepare(
|
||||||
|
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||||
|
);
|
||||||
|
const deleteMonthlyStmt = db.prepare(
|
||||||
|
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { rollupDay, videoId } of dailyGroups) {
|
||||||
|
deleteDailyStmt.run(rollupDay, videoId);
|
||||||
|
}
|
||||||
|
for (const { rollupMonth, videoId } of monthlyGroups) {
|
||||||
|
deleteMonthlyStmt.run(rollupMonth, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||||
|
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||||
|
}
|
||||||
|
|
||||||
function getAffectedRollupGroups(
|
function getAffectedRollupGroups(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
lastRollupSampleMs: number | string,
|
lastRollupSampleMs: number | string,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
KanjiOccurrenceRow,
|
KanjiOccurrenceRow,
|
||||||
KanjiStatsRow,
|
KanjiStatsRow,
|
||||||
KanjiWordRow,
|
KanjiWordRow,
|
||||||
|
SentenceSearchOptions,
|
||||||
|
SentenceSearchResultRow,
|
||||||
SessionEventRow,
|
SessionEventRow,
|
||||||
SimilarWordRow,
|
SimilarWordRow,
|
||||||
StatsExcludedWordRow,
|
StatsExcludedWordRow,
|
||||||
@@ -20,6 +22,56 @@ import { nowMs } from './time';
|
|||||||
|
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||||
|
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||||
|
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
||||||
|
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||||
|
|
||||||
|
function resolveSentenceSearchLimit(limit: number): number {
|
||||||
|
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
|
||||||
|
const normalized = Math.floor(limit);
|
||||||
|
if (normalized <= 0) return SENTENCE_SEARCH_DEFAULT_LIMIT;
|
||||||
|
return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitSentenceSearchTerms(query: string): string[] {
|
||||||
|
return query
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLikeTerm(term: string): string {
|
||||||
|
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmptyTerms(values: readonly string[] | undefined): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const terms: string[] = [];
|
||||||
|
for (const value of values ?? []) {
|
||||||
|
const term = value.trim();
|
||||||
|
if (!term || seen.has(term)) continue;
|
||||||
|
seen.add(term);
|
||||||
|
terms.push(term);
|
||||||
|
}
|
||||||
|
return terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeadwordCandidatesForSentenceSearchTerm(
|
||||||
|
term: string,
|
||||||
|
options: SentenceSearchOptions | undefined,
|
||||||
|
): string[] {
|
||||||
|
const headwords =
|
||||||
|
options?.headwordTerms
|
||||||
|
?.filter((entry) => entry.term === term)
|
||||||
|
.flatMap((entry) => entry.headwords) ?? [];
|
||||||
|
return uniqueNonEmptyTerms(headwords);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueKanji(text: string): string[] {
|
||||||
|
return Array.from(new Set(text.match(KANJI_PATTERN) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
|
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
|
||||||
const partOfSpeech =
|
const partOfSpeech =
|
||||||
@@ -211,6 +263,70 @@ export function getKanjiOccurrences(
|
|||||||
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
|
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchSubtitleSentences(
|
||||||
|
db: DatabaseSync,
|
||||||
|
query: string,
|
||||||
|
limit = SENTENCE_SEARCH_DEFAULT_LIMIT,
|
||||||
|
options?: SentenceSearchOptions,
|
||||||
|
): SentenceSearchResultRow[] {
|
||||||
|
const terms = splitSentenceSearchTerms(query);
|
||||||
|
if (terms.length === 0) return [];
|
||||||
|
const resolvedLimit = resolveSentenceSearchLimit(limit);
|
||||||
|
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const params: string[] = [];
|
||||||
|
for (const term of terms) {
|
||||||
|
const likeTerm = `%${escapeLikeTerm(term)}%`;
|
||||||
|
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
|
||||||
|
const headwordClause =
|
||||||
|
headwords.length > 0
|
||||||
|
? `
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM imm_word_line_occurrences o
|
||||||
|
JOIN imm_words w ON w.id = o.word_id
|
||||||
|
WHERE o.line_id = l.line_id
|
||||||
|
AND w.headword IN (${headwords.map(() => '?').join(', ')})
|
||||||
|
)
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
clauses.push(`
|
||||||
|
(
|
||||||
|
l.text LIKE ? ESCAPE '\\'
|
||||||
|
OR v.canonical_title LIKE ? ESCAPE '\\'
|
||||||
|
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
|
||||||
|
${headwordClause}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
l.anime_id AS animeId,
|
||||||
|
a.canonical_title AS animeTitle,
|
||||||
|
l.video_id AS videoId,
|
||||||
|
v.canonical_title AS videoTitle,
|
||||||
|
v.source_path AS sourcePath,
|
||||||
|
l.secondary_text AS secondaryText,
|
||||||
|
l.session_id AS sessionId,
|
||||||
|
l.line_index AS lineIndex,
|
||||||
|
l.segment_start_ms AS segmentStartMs,
|
||||||
|
l.segment_end_ms AS segmentEndMs,
|
||||||
|
l.text AS text
|
||||||
|
FROM imm_subtitle_lines l
|
||||||
|
JOIN imm_videos v ON v.video_id = l.video_id
|
||||||
|
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||||
|
WHERE ${clauses.join(' AND ')}
|
||||||
|
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export function getSessionEvents(
|
export function getSessionEvents(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
@@ -287,24 +403,38 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
|
|||||||
reading: string;
|
reading: string;
|
||||||
} | null;
|
} | null;
|
||||||
if (!word || word.headword.trim() === '') return [];
|
if (!word || word.headword.trim() === '') return [];
|
||||||
|
|
||||||
|
const clauses: string[] = [];
|
||||||
|
const params: string[] = [];
|
||||||
|
const reading = word.reading.trim();
|
||||||
|
if (reading !== '') {
|
||||||
|
clauses.push('reading = ?');
|
||||||
|
params.push(word.reading);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kanji of uniqueKanji(word.headword)) {
|
||||||
|
clauses.push("headword LIKE ? ESCAPE '\\'");
|
||||||
|
params.push(`%${escapeLikeTerm(kanji)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length === 0) return [];
|
||||||
|
|
||||||
|
const orderBy =
|
||||||
|
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
|
||||||
|
const orderParams = reading !== '' ? [word.reading] : [];
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT id AS wordId, headword, word, reading, frequency
|
SELECT id AS wordId, headword, word, reading, frequency
|
||||||
FROM imm_words
|
FROM imm_words
|
||||||
WHERE id != ?
|
WHERE id != ?
|
||||||
AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
|
AND (${clauses.join(' OR ')})
|
||||||
ORDER BY frequency DESC
|
ORDER BY ${orderBy}
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(
|
.all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
|
||||||
wordId,
|
|
||||||
word.reading,
|
|
||||||
`%${word.headword.charAt(0)}%`,
|
|
||||||
`%${word.headword.charAt(word.headword.length - 1)}%`,
|
|
||||||
limit,
|
|
||||||
) as SimilarWordRow[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||||
import { rebuildRollupsInTransaction } from './maintenance';
|
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||||
@@ -474,13 +474,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
|||||||
const sessionIds = [sessionId];
|
const sessionIds = [sessionId];
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||||
|
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -492,13 +493,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
|||||||
if (sessionIds.length === 0) return;
|
if (sessionIds.length === 0) return;
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||||
|
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -536,7 +538,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
|||||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -367,6 +367,29 @@ export interface KanjiOccurrenceRow {
|
|||||||
occurrenceCount: number;
|
occurrenceCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchResultRow {
|
||||||
|
animeId: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
videoId: number;
|
||||||
|
videoTitle: string;
|
||||||
|
sourcePath: string | null;
|
||||||
|
secondaryText: string | null;
|
||||||
|
sessionId: number;
|
||||||
|
lineIndex: number;
|
||||||
|
segmentStartMs: number | null;
|
||||||
|
segmentEndMs: number | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchHeadwordTerm {
|
||||||
|
term: string;
|
||||||
|
headwords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchOptions {
|
||||||
|
headwordTerms?: SentenceSearchHeadwordTerm[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionEventRow {
|
export interface SessionEventRow {
|
||||||
eventType: number;
|
eventType: number;
|
||||||
tsMs: number;
|
tsMs: number;
|
||||||
|
|||||||
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
|
|||||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
|
||||||
|
const { deps, state } = createDeps({
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await dispatchMpvProtocolMessage(
|
||||||
|
{
|
||||||
|
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
|
data: [
|
||||||
|
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
|
||||||
|
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||||
const { deps, state } = createDeps();
|
const { deps, state } = createDeps();
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
|||||||
return `id:${track.id}`;
|
return `id:${track.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
|
||||||
|
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
|
||||||
|
return /\b(signs?|songs?)\b/.test(label);
|
||||||
|
}
|
||||||
|
|
||||||
function pickSecondarySubtitleTrackId(
|
function pickSecondarySubtitleTrackId(
|
||||||
tracks: Array<Record<string, unknown>>,
|
tracks: Array<Record<string, unknown>>,
|
||||||
preferredLanguages: string[],
|
preferredLanguages: string[],
|
||||||
@@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId(
|
|||||||
const uniqueTracks = [...dedupedTracks.values()];
|
const uniqueTracks = [...dedupedTracks.values()];
|
||||||
|
|
||||||
for (const language of normalizedLanguages) {
|
for (const language of normalizedLanguages) {
|
||||||
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
const languageTracks = uniqueTracks.filter((track) => track.lang === language);
|
||||||
|
if (languageTracks.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
|
||||||
|
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
|
||||||
|
|
||||||
|
const selectedMatch = candidateTracks.find((track) => track.selected);
|
||||||
if (selectedMatch) {
|
if (selectedMatch) {
|
||||||
return selectedMatch.id;
|
return selectedMatch.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = uniqueTracks.find((track) => track.lang === language);
|
const match = candidateTracks[0];
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.id;
|
return match.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { runCommand, type CommandResult } from '../../subsync/utils';
|
||||||
|
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
|
||||||
|
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
|
||||||
|
|
||||||
|
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus'];
|
||||||
|
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese'];
|
||||||
|
const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
|
||||||
|
const TIMING_TOLERANCE_SECONDS = 0.25;
|
||||||
|
const SAME_TIMING_EPSILON_SECONDS = 0.001;
|
||||||
|
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
|
||||||
|
const FALLBACK_ALASS_PATHS = [
|
||||||
|
'/opt/homebrew/bin/alass-cli',
|
||||||
|
'/opt/homebrew/bin/alass',
|
||||||
|
'/usr/local/bin/alass-cli',
|
||||||
|
'/usr/local/bin/alass',
|
||||||
|
'/usr/bin/alass',
|
||||||
|
];
|
||||||
|
|
||||||
|
type SidecarCandidate = {
|
||||||
|
path: string;
|
||||||
|
languageRank: number;
|
||||||
|
extensionRank: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RetimedSubtitleCacheEntry = {
|
||||||
|
path: string;
|
||||||
|
cleanupDir: string;
|
||||||
|
promise?: Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetimedSubtitleCommandRunner = (
|
||||||
|
alassPath: string,
|
||||||
|
referencePath: string,
|
||||||
|
inputPath: string,
|
||||||
|
outputPath: string,
|
||||||
|
) => Promise<CommandResult>;
|
||||||
|
|
||||||
|
export type RetimedSecondarySubtitleInput = {
|
||||||
|
sourcePath: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
languages?: readonly string[];
|
||||||
|
primaryLanguages?: readonly string[];
|
||||||
|
alassPath?: string | null;
|
||||||
|
runAlass?: RetimedSubtitleCommandRunner;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retimedSubtitleCache = new Map<string, RetimedSubtitleCacheEntry>();
|
||||||
|
let retimedSubtitleCleanupRegistered = false;
|
||||||
|
|
||||||
|
function unique(values: string[]): string[] {
|
||||||
|
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandPreferredLanguages(
|
||||||
|
languages: readonly string[] | undefined,
|
||||||
|
fallback: readonly string[],
|
||||||
|
): string[] {
|
||||||
|
const normalized = unique(
|
||||||
|
(languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean),
|
||||||
|
);
|
||||||
|
const base = normalized.length > 0 ? normalized : [...fallback];
|
||||||
|
const expanded: string[] = [];
|
||||||
|
for (const language of base) {
|
||||||
|
expanded.push(language);
|
||||||
|
if (isEnglishYoutubeLang(language)) {
|
||||||
|
expanded.push(...DEFAULT_SECONDARY_SUBTITLE_LANGUAGES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExecutableFile(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathEntries(): string[] {
|
||||||
|
const entries = (process.env.PATH ?? '')
|
||||||
|
.split(path.delimiter)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function executableNames(name: string): string[] {
|
||||||
|
if (process.platform !== 'win32') return [name];
|
||||||
|
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
|
||||||
|
.split(';')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (path.extname(name)) return [name];
|
||||||
|
return [name, ...extensions.map((extension) => `${name}${extension}`)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExecutable(names: readonly string[]): string {
|
||||||
|
for (const name of names) {
|
||||||
|
if (path.dirname(name) !== '.') {
|
||||||
|
return isExecutableFile(name) ? name : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of pathEntries()) {
|
||||||
|
for (const name of names) {
|
||||||
|
for (const executableName of executableNames(name)) {
|
||||||
|
const candidate = path.join(dir, executableName);
|
||||||
|
if (isExecutableFile(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of FALLBACK_ALASS_PATHS) {
|
||||||
|
if (isExecutableFile(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlassPath(configuredPath: string | null | undefined): string {
|
||||||
|
const trimmed = configuredPath?.trim() ?? '';
|
||||||
|
if (trimmed) {
|
||||||
|
return findExecutable([trimmed]);
|
||||||
|
}
|
||||||
|
return findExecutable(['alass', 'alass-cli']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSignature(filePath: string): string | null {
|
||||||
|
try {
|
||||||
|
const stats = statSync(filePath);
|
||||||
|
if (!stats.isFile()) return null;
|
||||||
|
return `${stats.size}:${stats.mtimeMs}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retimedCacheKey(
|
||||||
|
alassPath: string,
|
||||||
|
primaryPath: string,
|
||||||
|
secondaryPath: string,
|
||||||
|
): string | null {
|
||||||
|
const primarySignature = fileSignature(primaryPath);
|
||||||
|
const secondarySignature = fileSignature(secondaryPath);
|
||||||
|
if (!primarySignature || !secondarySignature) return null;
|
||||||
|
return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRetimedSubtitleCache(): void {
|
||||||
|
for (const entry of retimedSubtitleCache.values()) {
|
||||||
|
try {
|
||||||
|
rmSync(entry.cleanupDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best-effort temp cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
retimedSubtitleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRetimedSubtitleCleanup(): void {
|
||||||
|
if (retimedSubtitleCleanupRegistered) return;
|
||||||
|
retimedSubtitleCleanupRegistered = true;
|
||||||
|
process.once('exit', cleanupRetimedSubtitleCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRetimedSecondarySubtitleCache(): void {
|
||||||
|
cleanupRetimedSubtitleCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLanguageSuffix(value: string): string[] {
|
||||||
|
const normalizedWhole = normalizeYoutubeLangCode(value);
|
||||||
|
const tokens = value
|
||||||
|
.split(/[^A-Za-z0-9-]+/g)
|
||||||
|
.map((token) => normalizeYoutubeLangCode(token))
|
||||||
|
.filter(Boolean);
|
||||||
|
return unique([normalizedWhole, ...tokens]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageTokenMatches(token: string, preferredLanguage: string): boolean {
|
||||||
|
if (token === preferredLanguage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (token.startsWith(`${preferredLanguage}-`) || preferredLanguage.startsWith(`${token}-`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isEnglishYoutubeLang(token) && isEnglishYoutubeLang(preferredLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLanguageRank(suffix: string, preferredLanguages: string[]): number {
|
||||||
|
const tokens = splitLanguageSuffix(suffix);
|
||||||
|
for (let index = 0; index < preferredLanguages.length; index += 1) {
|
||||||
|
const preferredLanguage = preferredLanguages[index]!;
|
||||||
|
if (tokens.some((token) => languageTokenMatches(token, preferredLanguage))) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionRank(ext: string): number {
|
||||||
|
if (ext === '.srt') return 0;
|
||||||
|
if (ext === '.vtt') return 1;
|
||||||
|
if (ext === '.ass') return 2;
|
||||||
|
if (ext === '.ssa') return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSidecarSubtitleCandidates(
|
||||||
|
sourcePath: string,
|
||||||
|
preferredLanguages: string[],
|
||||||
|
): SidecarCandidate[] {
|
||||||
|
const source = path.parse(sourcePath);
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(source.dir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${source.name}.`;
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
const parsed = path.parse(entry);
|
||||||
|
const ext = parsed.ext.toLowerCase();
|
||||||
|
if (!SUPPORTED_SUBTITLE_EXTENSIONS.has(ext) || !parsed.name.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const suffix = parsed.name.slice(prefix.length);
|
||||||
|
const languageRank = resolveLanguageRank(suffix, preferredLanguages);
|
||||||
|
if (!Number.isFinite(languageRank)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: path.join(source.dir, entry),
|
||||||
|
languageRank,
|
||||||
|
extensionRank: extensionRank(ext),
|
||||||
|
name: entry,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((candidate): candidate is SidecarCandidate => candidate !== null)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.languageRank !== right.languageRank) return left.languageRank - right.languageRank;
|
||||||
|
if (left.extensionRank !== right.extensionRank)
|
||||||
|
return left.extensionRank - right.extensionRank;
|
||||||
|
return left.name.localeCompare(right.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineCueText(cues: SubtitleCue[]): string {
|
||||||
|
return unique(cues.map((cue) => cue.text.trim()).filter(Boolean))
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapSeconds(cue: SubtitleCue, startSeconds: number, endSeconds: number): number {
|
||||||
|
return (
|
||||||
|
Math.min(cue.endTime, endSeconds + TIMING_TOLERANCE_SECONDS) -
|
||||||
|
Math.max(cue.startTime, startSeconds - TIMING_TOLERANCE_SECONDS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameCueTiming(left: SubtitleCue, right: SubtitleCue): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(left.startTime - right.startTime) <= SAME_TIMING_EPSILON_SECONDS &&
|
||||||
|
Math.abs(left.endTime - right.endTime) <= SAME_TIMING_EPSILON_SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCueTimingMatch(
|
||||||
|
startSeconds: number,
|
||||||
|
endSeconds: number,
|
||||||
|
left: { cue: SubtitleCue; overlap: number },
|
||||||
|
right: { cue: SubtitleCue; overlap: number },
|
||||||
|
): number {
|
||||||
|
if (left.overlap !== right.overlap) {
|
||||||
|
return right.overlap - left.overlap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftStartDistance = Math.abs(left.cue.startTime - startSeconds);
|
||||||
|
const rightStartDistance = Math.abs(right.cue.startTime - startSeconds);
|
||||||
|
if (leftStartDistance !== rightStartDistance) {
|
||||||
|
return leftStartDistance - rightStartDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftEndDistance = Math.abs(left.cue.endTime - endSeconds);
|
||||||
|
const rightEndDistance = Math.abs(right.cue.endTime - endSeconds);
|
||||||
|
if (leftEndDistance !== rightEndDistance) {
|
||||||
|
return leftEndDistance - rightEndDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.cue.startTime - right.cue.startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number): string {
|
||||||
|
const startSeconds = startMs / 1000;
|
||||||
|
const endSeconds = endMs / 1000;
|
||||||
|
const midpointSeconds = (startSeconds + endSeconds) / 2;
|
||||||
|
|
||||||
|
const midpointMatches = cues
|
||||||
|
.filter(
|
||||||
|
(cue) =>
|
||||||
|
cue.startTime - TIMING_TOLERANCE_SECONDS <= midpointSeconds &&
|
||||||
|
cue.endTime + TIMING_TOLERANCE_SECONDS >= midpointSeconds,
|
||||||
|
)
|
||||||
|
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
|
||||||
|
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
|
||||||
|
const [bestMidpointMatch] = midpointMatches;
|
||||||
|
const midpointText = bestMidpointMatch
|
||||||
|
? combineCueText(
|
||||||
|
midpointMatches
|
||||||
|
.filter((match) => isSameCueTiming(match.cue, bestMidpointMatch.cue))
|
||||||
|
.map((match) => match.cue),
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
if (midpointText) {
|
||||||
|
return midpointText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bestOverlap] = cues
|
||||||
|
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
|
||||||
|
.filter((entry) => entry.overlap > 0)
|
||||||
|
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
|
||||||
|
return bestOverlap ? bestOverlap.cue.text.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
const cues = parseSubtitleCues(content, filePath);
|
||||||
|
return findCueTextAtTiming(cues, startMs, endMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultRunAlass(
|
||||||
|
alassPath: string,
|
||||||
|
referencePath: string,
|
||||||
|
inputPath: string,
|
||||||
|
outputPath: string,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retimeSecondarySubtitle(input: {
|
||||||
|
alassPath: string;
|
||||||
|
primaryPath: string;
|
||||||
|
secondaryPath: string;
|
||||||
|
runAlass: RetimedSubtitleCommandRunner;
|
||||||
|
}): Promise<string> {
|
||||||
|
const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath);
|
||||||
|
if (!key) return '';
|
||||||
|
|
||||||
|
const cached = retimedSubtitleCache.get(key);
|
||||||
|
if (cached?.promise) {
|
||||||
|
return cached.promise;
|
||||||
|
}
|
||||||
|
if (cached && existsSync(cached.path)) {
|
||||||
|
return cached.path;
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
retimedSubtitleCache.delete(key);
|
||||||
|
try {
|
||||||
|
rmSync(cached.cleanupDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRetimedSubtitleCleanup();
|
||||||
|
const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-'));
|
||||||
|
const parsedSecondary = path.parse(input.secondaryPath);
|
||||||
|
const outputPath = path.join(
|
||||||
|
cleanupDir,
|
||||||
|
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
|
||||||
|
entry.promise = input
|
||||||
|
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
|
||||||
|
.then((result) => {
|
||||||
|
if (!result.ok || !existsSync(outputPath)) {
|
||||||
|
rmSync(cleanupDir, { recursive: true, force: true });
|
||||||
|
retimedSubtitleCache.delete(key);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
entry.promise = undefined;
|
||||||
|
return outputPath;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
rmSync(cleanupDir, { recursive: true, force: true });
|
||||||
|
retimedSubtitleCache.delete(key);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
retimedSubtitleCache.set(key, entry);
|
||||||
|
return entry.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSecondarySubtitleTextFromSidecar(input: {
|
||||||
|
sourcePath: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
languages?: readonly string[];
|
||||||
|
}): string {
|
||||||
|
if (!input.sourcePath || !existsSync(input.sourcePath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!statSync(input.sourcePath).isFile()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredLanguages = expandPreferredLanguages(
|
||||||
|
input.languages,
|
||||||
|
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||||
|
);
|
||||||
|
const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs);
|
||||||
|
if (text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next matching sidecar.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
|
||||||
|
input: RetimedSecondarySubtitleInput,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!input.sourcePath || !existsSync(input.sourcePath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!statSync(input.sourcePath).isFile()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const alassPath = resolveAlassPath(input.alassPath);
|
||||||
|
if (!alassPath) return '';
|
||||||
|
|
||||||
|
const primaryLanguages = expandPreferredLanguages(
|
||||||
|
input.primaryLanguages,
|
||||||
|
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
|
||||||
|
);
|
||||||
|
const secondaryLanguages = expandPreferredLanguages(
|
||||||
|
input.languages,
|
||||||
|
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||||
|
);
|
||||||
|
const primaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, primaryLanguages);
|
||||||
|
const secondaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, secondaryLanguages);
|
||||||
|
const runAlass = input.runAlass ?? defaultRunAlass;
|
||||||
|
|
||||||
|
for (const primary of primaryCandidates) {
|
||||||
|
for (const secondary of secondaryCandidates) {
|
||||||
|
if (primary.path === secondary.path) continue;
|
||||||
|
try {
|
||||||
|
const retimedPath = await retimeSecondarySubtitle({
|
||||||
|
alassPath,
|
||||||
|
primaryPath: primary.path,
|
||||||
|
secondaryPath: secondary.path,
|
||||||
|
runAlass,
|
||||||
|
});
|
||||||
|
if (!retimedPath) continue;
|
||||||
|
const text = readCueTextAtTiming(retimedPath, input.startMs, input.endMs);
|
||||||
|
if (text) return text;
|
||||||
|
} catch {
|
||||||
|
// Try the next sidecar pair.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
|
import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js';
|
||||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
import { basename, extname, resolve, sep } from 'node:path';
|
import { basename, extname, resolve, sep } from 'node:path';
|
||||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||||
@@ -7,6 +8,7 @@ import { Readable } from 'node:stream';
|
|||||||
import { MediaGenerator } from '../../media-generator.js';
|
import { MediaGenerator } from '../../media-generator.js';
|
||||||
import { AnkiConnectClient } from '../../anki-connect.js';
|
import { AnkiConnectClient } from '../../anki-connect.js';
|
||||||
import type { AnkiConnectConfig } from '../../types.js';
|
import type { AnkiConnectConfig } from '../../types.js';
|
||||||
|
import { createLogger } from '../../logger.js';
|
||||||
import {
|
import {
|
||||||
getConfiguredSentenceFieldName,
|
getConfiguredSentenceFieldName,
|
||||||
getConfiguredTranslationFieldName,
|
getConfiguredTranslationFieldName,
|
||||||
@@ -15,18 +17,50 @@ import {
|
|||||||
} from '../../anki-field-config.js';
|
} from '../../anki-field-config.js';
|
||||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||||
|
import {
|
||||||
|
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||||
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
|
type RetimedSecondarySubtitleInput,
|
||||||
|
} from './secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
type StatsServerNoteInfo = {
|
type StatsServerNoteInfo = {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StatsServerMediaGenerator = {
|
||||||
|
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
|
||||||
|
generateScreenshot: (
|
||||||
|
...args: Parameters<MediaGenerator['generateScreenshot']>
|
||||||
|
) => Promise<Buffer | null>;
|
||||||
|
generateAnimatedImage: (
|
||||||
|
...args: Parameters<MediaGenerator['generateAnimatedImage']>
|
||||||
|
) => Promise<Buffer | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatsMiningTimingEvent = {
|
||||||
|
mode: 'word' | 'sentence' | 'audio';
|
||||||
|
phase: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
noteId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type StatsExcludedWordPayload = {
|
type StatsExcludedWordPayload = {
|
||||||
headword: string;
|
headword: string;
|
||||||
word: string;
|
word: string;
|
||||||
reading: string;
|
reading: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StatsCoverImagePayload = {
|
||||||
|
contentType: string;
|
||||||
|
dataUrl: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type StatsCoverBatchBody = {
|
||||||
|
animeIds?: unknown;
|
||||||
|
videoIds?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||||
if (raw === undefined) return fallback;
|
if (raw === undefined) return fallback;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -73,6 +107,62 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
|
|||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const rawId of raw) {
|
||||||
|
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
||||||
|
if (Number.isFinite(id) && id > 0) {
|
||||||
|
ids.add(Math.floor(id));
|
||||||
|
if (ids.size >= maxItems) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ids).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverImagePayload(
|
||||||
|
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||||
|
): StatsCoverImagePayload {
|
||||||
|
if (!art?.coverBlob) return null;
|
||||||
|
const bytes = new Uint8Array(art.coverBlob);
|
||||||
|
const contentType = detectImageContentType(bytes);
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectImageContentType(bytes: Uint8Array): string {
|
||||||
|
if (
|
||||||
|
bytes.length >= 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47
|
||||||
|
) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
bytes[0] === 0x52 &&
|
||||||
|
bytes[1] === 0x49 &&
|
||||||
|
bytes[2] === 0x46 &&
|
||||||
|
bytes[3] === 0x46 &&
|
||||||
|
bytes[8] === 0x57 &&
|
||||||
|
bytes[9] === 0x45 &&
|
||||||
|
bytes[10] === 0x42 &&
|
||||||
|
bytes[11] === 0x50
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStatsNoteFieldName(
|
function resolveStatsNoteFieldName(
|
||||||
noteInfo: StatsServerNoteInfo,
|
noteInfo: StatsServerNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -87,6 +177,57 @@ function resolveStatsNoteFieldName(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
const normalized = fieldName?.trim();
|
||||||
|
if (!normalized) continue;
|
||||||
|
const key = normalized.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatsWordMiningAudioFieldName(
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
(noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
|
||||||
|
: null) ??
|
||||||
|
ankiConfig.fields?.audio ??
|
||||||
|
'ExpressionAudio'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatsDirectMiningAudioFieldNames(
|
||||||
|
ankiConfig: AnkiConnectConfig,
|
||||||
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
|
mode: 'sentence' | 'audio',
|
||||||
|
): string[] {
|
||||||
|
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||||
|
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
|
||||||
|
return [configuredAudioField];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentenceAudioField = noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
|
||||||
|
: 'SentenceAudio';
|
||||||
|
const expressionAudioField = noteInfo
|
||||||
|
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
||||||
|
: configuredAudioField;
|
||||||
|
|
||||||
|
if (mode === 'sentence') {
|
||||||
|
return uniqueFieldNames(sentenceAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
|
||||||
|
}
|
||||||
|
|
||||||
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
|
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
|
||||||
const fetchHeaders = new Headers();
|
const fetchHeaders = new Headers();
|
||||||
for (const [name, value] of Object.entries(headers)) {
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
@@ -256,9 +397,19 @@ export interface StatsServerConfig {
|
|||||||
knownWordCachePath?: string;
|
knownWordCachePath?: string;
|
||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||||
|
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||||
|
secondarySubtitleLanguages?: string[];
|
||||||
|
getSecondarySubtitleLanguages?: () => string[] | undefined;
|
||||||
|
statsMiningAlassPath?: string;
|
||||||
|
getStatsMiningAlassPath?: () => string | null | undefined;
|
||||||
|
resolveRetimedSecondarySubtitleText?: (
|
||||||
|
input: RetimedSecondarySubtitleInput,
|
||||||
|
) => Promise<string> | string;
|
||||||
anilistRateLimiter?: AnilistRateLimiter;
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
|
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||||
@@ -279,6 +430,52 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
|||||||
'.woff2': 'font/woff2',
|
'.woff2': 'font/woff2',
|
||||||
};
|
};
|
||||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||||
|
const statsMiningLogger = createLogger('stats:mining');
|
||||||
|
|
||||||
|
function defaultNowMs(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!normalized) return fallback;
|
||||||
|
return !['0', 'false', 'no', 'off'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
result.push(normalized);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSentenceSearchOptions(
|
||||||
|
query: string,
|
||||||
|
searchByHeadword: boolean,
|
||||||
|
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
|
||||||
|
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
|
||||||
|
if (!searchByHeadword) return undefined;
|
||||||
|
|
||||||
|
const terms = splitSentenceSearchTerms(query);
|
||||||
|
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
|
||||||
|
for (const term of terms) {
|
||||||
|
const resolved = resolveSentenceSearchHeadwords
|
||||||
|
? await resolveSentenceSearchHeadwords(term)
|
||||||
|
: [term];
|
||||||
|
const headwords = uniqueNonEmptyStrings(resolved);
|
||||||
|
if (headwords.length > 0) {
|
||||||
|
headwordTerms.push({ term, headwords });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAnkiNotePreview(
|
function buildAnkiNotePreview(
|
||||||
fields: Record<string, { value: string }>,
|
fields: Record<string, { value: string }>,
|
||||||
@@ -340,12 +537,81 @@ export function createStatsApp(
|
|||||||
knownWordCachePath?: string;
|
knownWordCachePath?: string;
|
||||||
mpvSocketPath?: string;
|
mpvSocketPath?: string;
|
||||||
ankiConnectConfig?: AnkiConnectConfig;
|
ankiConnectConfig?: AnkiConnectConfig;
|
||||||
|
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||||
|
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||||
|
secondarySubtitleLanguages?: string[];
|
||||||
|
getSecondarySubtitleLanguages?: () => string[] | undefined;
|
||||||
|
statsMiningAlassPath?: string;
|
||||||
|
getStatsMiningAlassPath?: () => string | null | undefined;
|
||||||
|
resolveRetimedSecondarySubtitleText?: (
|
||||||
|
input: RetimedSecondarySubtitleInput,
|
||||||
|
) => Promise<string> | string;
|
||||||
anilistRateLimiter?: AnilistRateLimiter;
|
anilistRateLimiter?: AnilistRateLimiter;
|
||||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||||
resolveAnkiNoteId?: (noteId: number) => number;
|
resolveAnkiNoteId?: (noteId: number) => number;
|
||||||
|
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||||
|
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||||
|
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||||
|
nowMs?: () => number;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
const nowMs = options?.nowMs ?? defaultNowMs;
|
||||||
|
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
|
||||||
|
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
|
||||||
|
const getSecondarySubtitleLanguages = (): string[] =>
|
||||||
|
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
|
||||||
|
const getStatsMiningAlassPath = (): string | null | undefined =>
|
||||||
|
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
|
||||||
|
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
|
||||||
|
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
|
||||||
|
if (configuredDeckName) return configuredDeckName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
|
||||||
|
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
|
||||||
|
} catch (error) {
|
||||||
|
statsMiningLogger.warn(
|
||||||
|
'Failed to resolve Yomitan Anki deck for stats mining:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
||||||
|
options?.onMiningTiming?.(event);
|
||||||
|
statsMiningLogger.debug(
|
||||||
|
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeMiningPhase = async <T>(
|
||||||
|
mode: StatsMiningTimingEvent['mode'],
|
||||||
|
phase: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
details?: (value: T) => Partial<StatsMiningTimingEvent>,
|
||||||
|
): Promise<T> => {
|
||||||
|
const startedAtMs = nowMs();
|
||||||
|
try {
|
||||||
|
const value = await fn();
|
||||||
|
recordMiningTiming({
|
||||||
|
mode,
|
||||||
|
phase,
|
||||||
|
elapsedMs: nowMs() - startedAtMs,
|
||||||
|
...details?.(value),
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
recordMiningTiming({
|
||||||
|
mode,
|
||||||
|
phase,
|
||||||
|
elapsedMs: nowMs() - startedAtMs,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/api/stats/overview', async (c) => {
|
app.get('/api/stats/overview', async (c) => {
|
||||||
const [rawSessions, rollups, hints] = await Promise.all([
|
const [rawSessions, rollups, hints] = await Promise.all([
|
||||||
@@ -509,6 +775,20 @@ export function createStatsApp(
|
|||||||
return c.json(occurrences);
|
return c.json(occurrences);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/stats/sentences/search', async (c) => {
|
||||||
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
|
if (!query) return c.json([]);
|
||||||
|
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||||
|
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||||
|
const searchOptions = await buildSentenceSearchOptions(
|
||||||
|
query,
|
||||||
|
searchByHeadword,
|
||||||
|
options?.resolveSentenceSearchHeadwords,
|
||||||
|
);
|
||||||
|
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||||
|
return c.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/stats/kanji', async (c) => {
|
app.get('/api/stats/kanji', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
const kanji = await tracker.getKanjiStats(limit);
|
const kanji = await tracker.getKanjiStats(limit);
|
||||||
@@ -707,14 +987,36 @@ export function createStatsApp(
|
|||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/stats/covers', async (c) => {
|
||||||
|
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||||
|
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||||
|
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||||
|
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||||
|
const media: Record<number, StatsCoverImagePayload> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
animeIds.map(async (animeId) => {
|
||||||
|
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
videoIds.map(async (videoId) => {
|
||||||
|
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json({ anime, media });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
if (animeId <= 0) return c.body(null, 404);
|
if (animeId <= 0) return c.body(null, 404);
|
||||||
const art = await tracker.getAnimeCoverArt(animeId);
|
const art = await tracker.getAnimeCoverArt(animeId);
|
||||||
if (!art?.coverBlob) return c.body(null, 404);
|
if (!art?.coverBlob) return c.body(null, 404);
|
||||||
return new Response(new Uint8Array(art.coverBlob), {
|
const bytes = new Uint8Array(art.coverBlob);
|
||||||
|
return new Response(bytes, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': detectImageContentType(bytes),
|
||||||
'Cache-Control': 'public, max-age=86400',
|
'Cache-Control': 'public, max-age=86400',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -729,9 +1031,10 @@ export function createStatsApp(
|
|||||||
art = await tracker.getCoverArt(videoId);
|
art = await tracker.getCoverArt(videoId);
|
||||||
}
|
}
|
||||||
if (!art?.coverBlob) return c.body(null, 404);
|
if (!art?.coverBlob) return c.body(null, 404);
|
||||||
return new Response(new Uint8Array(art.coverBlob), {
|
const bytes = new Uint8Array(art.coverBlob);
|
||||||
|
return new Response(bytes, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': detectImageContentType(bytes),
|
||||||
'Cache-Control': 'public, max-age=604800',
|
'Cache-Control': 'public, max-age=604800',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -754,8 +1057,9 @@ export function createStatsApp(
|
|||||||
app.post('/api/stats/anki/browse', async (c) => {
|
app.post('/api/stats/anki/browse', async (c) => {
|
||||||
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
||||||
if (noteId <= 0) return c.body(null, 400);
|
if (noteId <= 0) return c.body(null, 400);
|
||||||
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:8765', {
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
@@ -791,7 +1095,8 @@ export function createStatsApp(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:8765', {
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
|
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||||
@@ -807,7 +1112,7 @@ export function createStatsApp(
|
|||||||
return c.json(
|
return c.json(
|
||||||
(result.result ?? []).map((note) => ({
|
(result.result ?? []).map((note) => ({
|
||||||
...note,
|
...note,
|
||||||
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig),
|
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -822,7 +1127,8 @@ export function createStatsApp(
|
|||||||
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
|
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
|
||||||
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
|
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
|
||||||
const word = typeof body?.word === 'string' ? body.word.trim() : '';
|
const word = typeof body?.word === 'string' ? body.word.trim() : '';
|
||||||
const secondaryText = typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
const bodySecondaryText =
|
||||||
|
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
||||||
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
|
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
|
||||||
const rawMode = c.req.query('mode');
|
const rawMode = c.req.query('mode');
|
||||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||||
@@ -830,18 +1136,51 @@ export function createStatsApp(
|
|||||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||||
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
||||||
}
|
}
|
||||||
|
if (endMs <= startMs) {
|
||||||
|
return c.json({ error: 'endMs must be greater than startMs' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(sourcePath)) {
|
if (!existsSync(sourcePath)) {
|
||||||
return c.json({ error: 'File not found' }, 404);
|
return c.json({ error: 'File not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ankiConfig = options?.ankiConnectConfig;
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
if (!ankiConfig) {
|
if (!ankiConfig) {
|
||||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
||||||
}
|
}
|
||||||
|
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||||
|
let retimedSecondaryText = '';
|
||||||
|
if (mode === 'sentence' && !bodySecondaryText) {
|
||||||
|
try {
|
||||||
|
retimedSecondaryText = await (
|
||||||
|
options?.resolveRetimedSecondarySubtitleText ??
|
||||||
|
resolveRetimedSecondarySubtitleTextFromSidecar
|
||||||
|
)({
|
||||||
|
sourcePath,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
languages: secondarySubtitleLanguages,
|
||||||
|
alassPath: getStatsMiningAlassPath(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
statsMiningLogger.warn(
|
||||||
|
'Failed to resolve retimed secondary subtitle for stats mining:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const secondaryText =
|
||||||
|
bodySecondaryText ||
|
||||||
|
retimedSecondaryText ||
|
||||||
|
resolveSecondarySubtitleTextFromSidecar({
|
||||||
|
sourcePath,
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
languages: secondarySubtitleLanguages,
|
||||||
|
});
|
||||||
|
|
||||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||||
const mediaGen = new MediaGenerator();
|
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||||
|
|
||||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||||
@@ -865,7 +1204,9 @@ export function createStatsApp(
|
|||||||
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
||||||
|
|
||||||
const audioPromise = generateAudio
|
const audioPromise = generateAudio
|
||||||
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding)
|
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||||
|
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||||
|
)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
||||||
@@ -874,22 +1215,26 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (imageType === 'avif') {
|
if (imageType === 'avif') {
|
||||||
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
|
||||||
|
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||||
leadingStillDuration: animatedLeadInSeconds,
|
leadingStillDuration: animatedLeadInSeconds,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const midpointSec = (startSec + clampedEndSec) / 2;
|
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||||
return mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
return timeMiningPhase(mode, 'generateScreenshot', () =>
|
||||||
|
mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const imagePromise =
|
const imagePromise =
|
||||||
@@ -899,6 +1244,25 @@ export function createStatsApp(
|
|||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let noteId: number;
|
let noteId: number;
|
||||||
|
let effectiveDeckNamePromise: Promise<string> | null = null;
|
||||||
|
const getEffectiveDeckNameForRequest = (): Promise<string> => {
|
||||||
|
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
|
||||||
|
return effectiveDeckNamePromise;
|
||||||
|
};
|
||||||
|
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
|
||||||
|
const deckName = await getEffectiveDeckNameForRequest();
|
||||||
|
if (!deckName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
|
||||||
|
client.findCards(`nid:${id}`),
|
||||||
|
);
|
||||||
|
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`deck: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (mode === 'word') {
|
if (mode === 'word') {
|
||||||
if (!options?.addYomitanNote) {
|
if (!options?.addYomitanNote) {
|
||||||
@@ -906,7 +1270,12 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||||
options.addYomitanNote(word),
|
timeMiningPhase(
|
||||||
|
'word',
|
||||||
|
'addYomitanNote',
|
||||||
|
() => options.addYomitanNote!(word),
|
||||||
|
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||||
|
),
|
||||||
audioPromise,
|
audioPromise,
|
||||||
imagePromise,
|
imagePromise,
|
||||||
]);
|
]);
|
||||||
@@ -921,6 +1290,7 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
noteId = yomitanResult.value;
|
noteId = yomitanResult.value;
|
||||||
|
await moveNoteToConfiguredDeck(noteId);
|
||||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||||
if (audioResult.status === 'rejected')
|
if (audioResult.status === 'rejected')
|
||||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||||
@@ -928,10 +1298,19 @@ export function createStatsApp(
|
|||||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||||
|
|
||||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
|
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
||||||
try {
|
try {
|
||||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
const noteInfo = noteInfoResult[0] ?? null;
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||||
|
errors.push(`image: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||||
|
try {
|
||||||
const animatedLeadInSeconds = noteInfo
|
const animatedLeadInSeconds = noteInfo
|
||||||
? await resolveAnimatedImageLeadInSeconds({
|
? await resolveAnimatedImageLeadInSeconds({
|
||||||
config: ankiConfig,
|
config: ankiConfig,
|
||||||
@@ -946,22 +1325,27 @@ export function createStatsApp(
|
|||||||
errors.push(`image: ${(err as Error).message}`);
|
errors.push(`image: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
|
||||||
|
errors.push('audio: no audio generated');
|
||||||
|
}
|
||||||
|
if (generateImage && !imageBuffer) {
|
||||||
|
errors.push('image: no image generated');
|
||||||
|
}
|
||||||
|
|
||||||
const mediaFields: Record<string, string> = {};
|
const mediaFields: Record<string, string> = {};
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
|
||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
if (secondaryText) {
|
|
||||||
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||||
try {
|
try {
|
||||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||||
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
|
);
|
||||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`audio upload: ${(err as Error).message}`);
|
errors.push(`audio upload: ${(err as Error).message}`);
|
||||||
@@ -972,7 +1356,9 @@ export function createStatsApp(
|
|||||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||||
try {
|
try {
|
||||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
await timeMiningPhase('word', 'uploadImage', () =>
|
||||||
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
|
);
|
||||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`image upload: ${(err as Error).message}`);
|
errors.push(`image upload: ${(err as Error).message}`);
|
||||||
@@ -1000,7 +1386,9 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (Object.keys(mediaFields).length > 0) {
|
if (Object.keys(mediaFields).length > 0) {
|
||||||
try {
|
try {
|
||||||
await client.updateNoteFields(noteId, mediaFields);
|
await timeMiningPhase('word', 'updateNoteFields', () =>
|
||||||
|
client.updateNoteFields(noteId, mediaFields),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`update fields: ${(err as Error).message}`);
|
errors.push(`update fields: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -1009,32 +1397,24 @@ export function createStatsApp(
|
|||||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
|
|
||||||
|
|
||||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
|
||||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
|
||||||
if (audioResult.status === 'rejected')
|
|
||||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
|
||||||
if (imageResult.status === 'rejected')
|
|
||||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
|
||||||
|
|
||||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
|
||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||||
|
|
||||||
const fields: Record<string, string> = {
|
const fields: Record<string, string> = {
|
||||||
[sentenceFieldName]: highlightedSentence,
|
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (secondaryText) {
|
if (mode === 'sentence' && secondaryText) {
|
||||||
fields[translationFieldName] = secondaryText;
|
fields[translationFieldName] = secondaryText;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
||||||
if (word) {
|
if (mode === 'sentence') {
|
||||||
|
fields[wordFieldName] = sentence;
|
||||||
|
} else if (word) {
|
||||||
fields[wordFieldName] = word;
|
fields[wordFieldName] = word;
|
||||||
}
|
}
|
||||||
if (mode === 'sentence') {
|
if (mode === 'sentence') {
|
||||||
@@ -1045,23 +1425,62 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||||
const deck = ankiConfig.deck ?? 'Default';
|
|
||||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||||
|
|
||||||
try {
|
const addNotePromise = timeMiningPhase(
|
||||||
noteId = await client.addNote(deck, model, fields, tags);
|
mode,
|
||||||
} catch (err) {
|
'addNote',
|
||||||
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502);
|
async () =>
|
||||||
|
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
|
||||||
|
(id) => ({
|
||||||
|
noteId: id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
|
||||||
|
audioPromise,
|
||||||
|
imagePromise,
|
||||||
|
addNotePromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||||
|
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||||
|
if (audioResult.status === 'rejected')
|
||||||
|
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||||
|
if (imageResult.status === 'rejected')
|
||||||
|
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||||
|
|
||||||
|
if (addNoteResult.status === 'rejected') {
|
||||||
|
return c.json(
|
||||||
|
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
||||||
|
502,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
noteId = addNoteResult.value;
|
||||||
|
await moveNoteToConfiguredDeck(noteId);
|
||||||
|
|
||||||
const mediaFields: Record<string, string> = {};
|
const mediaFields: Record<string, string> = {};
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
let noteInfo: StatsServerNoteInfo | null = null;
|
||||||
|
if (audioBuffer) {
|
||||||
|
try {
|
||||||
|
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||||
|
noteInfo = noteInfoResult[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
noteInfo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||||
try {
|
try {
|
||||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
client.storeMediaFile(audioFilename, audioBuffer),
|
||||||
|
);
|
||||||
|
const audioValue = `[sound:${audioFilename}]`;
|
||||||
|
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
|
||||||
|
mediaFields[fieldName] = audioValue;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`audio upload: ${(err as Error).message}`);
|
errors.push(`audio upload: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -1071,7 +1490,9 @@ export function createStatsApp(
|
|||||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||||
try {
|
try {
|
||||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||||
|
client.storeMediaFile(imageFilename, imageBuffer),
|
||||||
|
);
|
||||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`image upload: ${(err as Error).message}`);
|
errors.push(`image upload: ${(err as Error).message}`);
|
||||||
@@ -1099,7 +1520,9 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (Object.keys(mediaFields).length > 0) {
|
if (Object.keys(mediaFields).length > 0) {
|
||||||
try {
|
try {
|
||||||
await client.updateNoteFields(noteId, mediaFields);
|
await timeMiningPhase(mode, 'updateNoteFields', () =>
|
||||||
|
client.updateNoteFields(noteId, mediaFields),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`update fields: ${(err as Error).message}`);
|
errors.push(`update fields: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
@@ -1139,9 +1562,17 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
|||||||
knownWordCachePath: config.knownWordCachePath,
|
knownWordCachePath: config.knownWordCachePath,
|
||||||
mpvSocketPath: config.mpvSocketPath,
|
mpvSocketPath: config.mpvSocketPath,
|
||||||
ankiConnectConfig: config.ankiConnectConfig,
|
ankiConnectConfig: config.ankiConnectConfig,
|
||||||
|
getAnkiConnectConfig: config.getAnkiConnectConfig,
|
||||||
|
getYomitanAnkiDeckName: config.getYomitanAnkiDeckName,
|
||||||
|
secondarySubtitleLanguages: config.secondarySubtitleLanguages,
|
||||||
|
getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages,
|
||||||
|
statsMiningAlassPath: config.statsMiningAlassPath,
|
||||||
|
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
|
||||||
|
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
|
||||||
anilistRateLimiter: config.anilistRateLimiter,
|
anilistRateLimiter: config.anilistRateLimiter,
|
||||||
addYomitanNote: config.addYomitanNote,
|
addYomitanNote: config.addYomitanNote,
|
||||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||||
|
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bunRuntime = globalThis as typeof globalThis & {
|
const bunRuntime = globalThis as typeof globalThis & {
|
||||||
|
|||||||
@@ -151,6 +151,56 @@ test('syncYomitanDefaultAnkiServer injects force override when enabled', async (
|
|||||||
assert.match(scriptValue, /forceOverride = true/);
|
assert.match(scriptValue, /forceOverride = true/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => {
|
||||||
|
const optionsFull = {
|
||||||
|
profileCurrent: 0,
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
anki: {
|
||||||
|
server: 'http://127.0.0.1:8766',
|
||||||
|
cardFormats: [
|
||||||
|
{ type: 'term', deck: 'Default', model: 'Mining Note', fields: {} },
|
||||||
|
{ type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} },
|
||||||
|
],
|
||||||
|
terms: { deck: 'Default', model: 'Legacy Note', fields: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let savedOptions: typeof optionsFull | null = null;
|
||||||
|
const deps = createDeps((script) =>
|
||||||
|
runInjectedYomitanScript(script, (action, params) => {
|
||||||
|
if (action === 'optionsGetFull') {
|
||||||
|
return JSON.parse(JSON.stringify(optionsFull));
|
||||||
|
}
|
||||||
|
if (action === 'setAllSettings') {
|
||||||
|
savedOptions = (params as { value: typeof optionsFull }).value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected action: ${action}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServer(
|
||||||
|
'http://127.0.0.1:8766',
|
||||||
|
deps,
|
||||||
|
{
|
||||||
|
error: () => undefined,
|
||||||
|
info: () => undefined,
|
||||||
|
},
|
||||||
|
{ deck: 'Minecraft', forceOverride: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(synced, true);
|
||||||
|
assert.ok(savedOptions);
|
||||||
|
const saved = savedOptions as typeof optionsFull;
|
||||||
|
assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft');
|
||||||
|
assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji');
|
||||||
|
assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft');
|
||||||
|
});
|
||||||
|
|
||||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||||
const deps = createDeps(async () => {
|
const deps = createDeps(async () => {
|
||||||
throw new Error('execute failed');
|
throw new Error('execute failed');
|
||||||
|
|||||||
@@ -1783,6 +1783,7 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
logger: LoggerLike,
|
logger: LoggerLike,
|
||||||
options?: {
|
options?: {
|
||||||
forceOverride?: boolean;
|
forceOverride?: boolean;
|
||||||
|
deck?: string;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const normalizedTargetServer = serverUrl.trim();
|
const normalizedTargetServer = serverUrl.trim();
|
||||||
@@ -1790,6 +1791,7 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const forceOverride = options?.forceOverride === true;
|
const forceOverride = options?.forceOverride === true;
|
||||||
|
const normalizedTargetDeck = options?.deck?.trim() ?? '';
|
||||||
|
|
||||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||||
const parserWindow = deps.getYomitanParserWindow();
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
@@ -1819,6 +1821,7 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||||
|
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
|
||||||
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
@@ -1843,9 +1846,8 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
|
|
||||||
const currentServerRaw = targetProfile.options.anki.server;
|
const currentServerRaw = targetProfile.options.anki.server;
|
||||||
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
||||||
if (currentServer === targetServer) {
|
let changed = false;
|
||||||
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
|
if (currentServer !== targetServer) {
|
||||||
}
|
|
||||||
const canReplaceCurrent =
|
const canReplaceCurrent =
|
||||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||||
if (!canReplaceCurrent) {
|
if (!canReplaceCurrent) {
|
||||||
@@ -1853,8 +1855,45 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
targetProfile.options.anki.server = targetServer;
|
targetProfile.options.anki.server = targetServer;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDeck) {
|
||||||
|
const cardFormats = Array.isArray(targetProfile.options.anki.cardFormats)
|
||||||
|
? targetProfile.options.anki.cardFormats
|
||||||
|
: [];
|
||||||
|
for (const cardFormat of cardFormats) {
|
||||||
|
if (
|
||||||
|
!cardFormat ||
|
||||||
|
typeof cardFormat !== "object" ||
|
||||||
|
cardFormat.type !== "term" ||
|
||||||
|
cardFormat.enabled === false
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentDeck = typeof cardFormat.deck === "string" ? cardFormat.deck.trim() : "";
|
||||||
|
if (currentDeck !== targetDeck) {
|
||||||
|
cardFormat.deck = targetDeck;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms = targetProfile.options.anki.terms;
|
||||||
|
if (terms && typeof terms === "object") {
|
||||||
|
const currentTermDeck = typeof terms.deck === "string" ? terms.deck.trim() : "";
|
||||||
|
if (currentTermDeck !== targetDeck) {
|
||||||
|
terms.deck = targetDeck;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
|
||||||
|
}
|
||||||
|
|
||||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||||
return { updated: true, matched: true, currentServer, targetServer };
|
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
+57
-18
@@ -1833,6 +1833,31 @@ function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
|
||||||
|
const fallback = term.trim() ? [term.trim()] : [];
|
||||||
|
try {
|
||||||
|
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
|
||||||
|
const tokens = tokenized?.tokens ?? [];
|
||||||
|
if (tokens.length === 0) return fallback;
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const headwords: string[] = [];
|
||||||
|
for (const token of tokens) {
|
||||||
|
const headword = (token.headword || token.surface).trim();
|
||||||
|
if (!headword || seen.has(headword)) continue;
|
||||||
|
seen.add(headword);
|
||||||
|
headwords.push(headword);
|
||||||
|
}
|
||||||
|
return headwords.length > 0 ? headwords : fallback;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
'Failed to resolve sentence-search headwords:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function signalCurrentSubtitleAutoplayReady(): void {
|
function signalCurrentSubtitleAutoplayReady(): void {
|
||||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||||
const payload = getCurrentAutoplaySubtitlePayload();
|
const payload = getCurrentAutoplaySubtitlePayload();
|
||||||
@@ -2240,6 +2265,18 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
|
|||||||
buildConfigHotReloadRuntimeMainDepsHandler(),
|
buildConfigHotReloadRuntimeMainDepsHandler(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
|
||||||
|
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||||
|
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
|
||||||
|
error: (message, ...args) => {
|
||||||
|
logger.error(message, ...args);
|
||||||
|
},
|
||||||
|
info: (message, ...args) => {
|
||||||
|
logger.info(message, ...args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const configSettingsRuntime = createConfigSettingsRuntime({
|
const configSettingsRuntime = createConfigSettingsRuntime({
|
||||||
fields: configSettingsFields,
|
fields: configSettingsFields,
|
||||||
getConfigPath: () => configService.getConfigPath(),
|
getConfigPath: () => configService.getConfigPath(),
|
||||||
@@ -2250,17 +2287,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
|||||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||||
createAnkiClient: (url) => new AnkiConnectClient(url),
|
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||||
getYomitanAnkiDeckName: async () => {
|
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
|
||||||
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
|
||||||
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
|
|
||||||
error: (message, ...args) => {
|
|
||||||
logger.error(message, ...args);
|
|
||||||
},
|
|
||||||
info: (message, ...args) => {
|
|
||||||
logger.info(message, ...args);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getSettingsWindow: () => appState.configSettingsWindow,
|
getSettingsWindow: () => appState.configSettingsWindow,
|
||||||
setSettingsWindow: (window) => {
|
setSettingsWindow: (window) => {
|
||||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||||
@@ -4441,14 +4468,20 @@ const startLocalStatsServer = (): void => {
|
|||||||
tracker,
|
tracker,
|
||||||
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
mpvSocketPath: appState.mpvSocketPath,
|
mpvSocketPath: appState.mpvSocketPath,
|
||||||
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
|
||||||
|
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
|
||||||
|
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
|
||||||
anilistRateLimiter,
|
anilistRateLimiter,
|
||||||
resolveAnkiNoteId: (noteId: number) =>
|
resolveAnkiNoteId: (noteId: number) =>
|
||||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||||
|
resolveSentenceSearchHeadwords,
|
||||||
addYomitanNote: async (word: string) => {
|
addYomitanNote: async (word: string) => {
|
||||||
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
||||||
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
forceOverride: true,
|
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||||
|
deck: ankiConnectConfig.deck,
|
||||||
});
|
});
|
||||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
@@ -5640,7 +5673,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
|||||||
return extension;
|
return extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastSyncedYomitanAnkiServer: string | null = null;
|
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
|
||||||
|
|
||||||
function getPreferredYomitanAnkiServerUrl(): string {
|
function getPreferredYomitanAnkiServerUrl(): string {
|
||||||
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
|
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
|
||||||
@@ -5671,7 +5704,10 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
||||||
|
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
|
||||||
|
const targetSettingsKey = `${targetUrl}\n${targetDeck}`;
|
||||||
|
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5687,12 +5723,15 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect),
|
forceOverride: ankiConnectConfig
|
||||||
|
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
|
||||||
|
: false,
|
||||||
|
deck: targetDeck,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (synced) {
|
if (synced) {
|
||||||
lastSyncedYomitanAnkiServer = targetUrl;
|
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
|
|||||||
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const startStatsServerBlock = source.match(
|
||||||
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(addYomitanNoteBlock);
|
||||||
|
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
||||||
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
|
});
|
||||||
|
|
||||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||||
|
import { resolveConfig } from '../../config/resolve';
|
||||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
import { createConfigSettingsRuntime } from './config-settings-runtime';
|
import { createConfigSettingsRuntime } from './config-settings-runtime';
|
||||||
|
|
||||||
@@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
|
|||||||
fields: [],
|
fields: [],
|
||||||
getConfigPath: () => '/tmp/config.jsonc',
|
getConfigPath: () => '/tmp/config.jsonc',
|
||||||
getRawConfig: () => ({}),
|
getRawConfig: () => ({}),
|
||||||
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
getConfig: () => ({
|
||||||
|
...deepCloneConfig(DEFAULT_CONFIG),
|
||||||
|
ankiConnect: {
|
||||||
|
...deepCloneConfig(DEFAULT_CONFIG).ankiConnect,
|
||||||
|
deck: 'Configured',
|
||||||
|
},
|
||||||
|
}),
|
||||||
getWarnings: () => [],
|
getWarnings: () => [],
|
||||||
reloadConfigStrict: () =>
|
reloadConfigStrict: () =>
|
||||||
({
|
({
|
||||||
@@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
|
|||||||
assert.ok(handler);
|
assert.ok(handler);
|
||||||
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
|
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => {
|
||||||
|
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-'));
|
||||||
|
const configPath = path.join(dir, 'config.jsonc');
|
||||||
|
fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let rawConfig = { ankiConnect: { deck: '' } };
|
||||||
|
let resolvedConfig = resolveConfig(rawConfig).resolved;
|
||||||
|
const runtime = createConfigSettingsRuntime({
|
||||||
|
fields: [],
|
||||||
|
getConfigPath: () => configPath,
|
||||||
|
getRawConfig: () => rawConfig,
|
||||||
|
getConfig: () => resolvedConfig,
|
||||||
|
getWarnings: () => [],
|
||||||
|
reloadConfigStrict: () => {
|
||||||
|
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||||
|
resolvedConfig = resolveConfig(rawConfig).resolved;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
config: resolvedConfig,
|
||||||
|
warnings: [],
|
||||||
|
path: configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSettingsWindow: () => null,
|
||||||
|
setSettingsWindow: () => undefined,
|
||||||
|
createSettingsWindow: () => ({}) as never,
|
||||||
|
settingsHtmlPath: '/tmp/settings.html',
|
||||||
|
openPath: async () => '',
|
||||||
|
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||||
|
createAnkiClient: () =>
|
||||||
|
({
|
||||||
|
deckNames: async () => [],
|
||||||
|
fieldNamesForDeck: async () => [],
|
||||||
|
modelNamesForDeck: async () => [],
|
||||||
|
modelNames: async () => [],
|
||||||
|
modelFieldNames: async () => [],
|
||||||
|
}) as never,
|
||||||
|
getYomitanAnkiDeckName: async () => 'Minecraft',
|
||||||
|
ipcMain: {
|
||||||
|
handle: (channel, listener) => {
|
||||||
|
handlers.set(channel, listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ipcChannels: IPC_CHANNELS.request,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.registerHandlers();
|
||||||
|
|
||||||
|
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
|
||||||
|
assert.ok(handler);
|
||||||
|
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' });
|
||||||
|
assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -193,13 +193,38 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
|
||||||
|
const normalizedDeckName = deckName.trim();
|
||||||
|
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
|
||||||
|
if (!normalizedDeckName || configuredDeckName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = savePatch({
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
op: 'set',
|
||||||
|
path: 'ankiConnect.deck',
|
||||||
|
value: normalizedDeckName,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
deps.log?.(
|
||||||
|
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
|
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
|
||||||
if (!deps.getYomitanAnkiDeckName) {
|
if (!deps.getYomitanAnkiDeckName) {
|
||||||
return { ok: true, value: '' };
|
return { ok: true, value: '' };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const value = await deps.getYomitanAnkiDeckName();
|
const value = await deps.getYomitanAnkiDeckName();
|
||||||
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
|
const deckName = typeof value === 'string' ? value.trim() : '';
|
||||||
|
persistInferredYomitanDeckIfEmpty(deckName);
|
||||||
|
return { ok: true, value: deckName };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -159,6 +159,30 @@ test('mpv subtitle timing handler runs AniList without timing tracker and passes
|
|||||||
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
|
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mpv subtitle timing handler skips invalid cue pairs until timing is complete', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createHandleMpvSubtitleTimingHandler({
|
||||||
|
recordImmersionSubtitleLine: (text, start, end) =>
|
||||||
|
calls.push(`immersion:${text}:${start}:${end}`),
|
||||||
|
hasSubtitleTimingTracker: () => true,
|
||||||
|
recordSubtitleTiming: (text, start, end) => calls.push(`timing:${text}:${start}:${end}`),
|
||||||
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
|
calls.push(`post-watch:${options?.watchedSeconds}`);
|
||||||
|
},
|
||||||
|
logError: () => calls.push('error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ text: 'line', start: 953.991, end: 953.891 });
|
||||||
|
handler({ text: 'line', start: 953.991, end: 956.56 });
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'post-watch:953.991',
|
||||||
|
'immersion:line:953.991:956.56',
|
||||||
|
'timing:line:953.991:956.56',
|
||||||
|
'post-watch:956.56',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('mpv event bindings register all expected events', () => {
|
test('mpv event bindings register all expected events', () => {
|
||||||
const seenEvents: string[] = [];
|
const seenEvents: string[] = [];
|
||||||
const bindHandlers = createBindMpvClientEventHandlers({
|
const bindHandlers = createBindMpvClientEventHandlers({
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
|
|||||||
Number.isFinite(end) ? end : 0,
|
Number.isFinite(end) ? end : 0,
|
||||||
);
|
);
|
||||||
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
|
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
|
||||||
if (text.trim()) {
|
if (text.trim() && Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||||||
deps.recordImmersionSubtitleLine(text, start, end);
|
deps.recordImmersionSubtitleLine(text, start, end);
|
||||||
if (deps.hasSubtitleTimingTracker()) {
|
if (deps.hasSubtitleTimingTracker()) {
|
||||||
deps.recordSubtitleTiming(text, start, end);
|
deps.recordSubtitleTiming(text, start, end);
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ test('overlay preload buffers only latest subtitle state until renderer listener
|
|||||||
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
assert.match(source, /onSubtitle:\s*\(callback:[\s\S]+?onSubtitleSetEvent\(callback\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('overlay preload does not expose the old mining image toast IPC path', () => {
|
||||||
|
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
||||||
|
|
||||||
|
assert.doesNotMatch(source, /MiningImagePayload|onMiningImage|IPC_CHANNELS\.event\.miningImage/);
|
||||||
|
});
|
||||||
|
|
||||||
test('overlay preload exposes queued pointer recovery requests', () => {
|
test('overlay preload exposes queued pointer recovery requests', () => {
|
||||||
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'preload.ts'), 'utf8');
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
import { writeStatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
||||||
import {
|
import {
|
||||||
createInvokeStatsWordHelperHandler,
|
createInvokeStatsWordHelperHandler,
|
||||||
|
createReadStatsYomitanDeckNameHandler,
|
||||||
|
type StatsWordHelperSpawnOptions,
|
||||||
type StatsWordHelperResponse,
|
type StatsWordHelperResponse,
|
||||||
} from './stats-word-helper-client';
|
} from './stats-word-helper-client';
|
||||||
|
|
||||||
@@ -58,19 +60,22 @@ async function waitForWordHelperResponse(responsePath: string): Promise<StatsWor
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({
|
const statsWordHelperDeps = {
|
||||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
createTempDir: (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||||
joinPath: (...parts) => path.join(...parts),
|
joinPath: (...parts: string[]) => path.join(...parts),
|
||||||
spawnHelper: async (options) => {
|
spawnHelper: async (options: StatsWordHelperSpawnOptions) => {
|
||||||
const childArgs = [
|
const childArgs = [
|
||||||
options.scriptPath,
|
options.scriptPath,
|
||||||
'--stats-word-helper-response-path',
|
'--stats-word-helper-response-path',
|
||||||
options.responsePath,
|
options.responsePath,
|
||||||
'--stats-word-helper-user-data-path',
|
'--stats-word-helper-user-data-path',
|
||||||
options.userDataPath,
|
options.userDataPath,
|
||||||
'--stats-word-helper-word',
|
|
||||||
options.word,
|
|
||||||
];
|
];
|
||||||
|
if (options.mode === 'deck-name') {
|
||||||
|
childArgs.push('--stats-word-helper-read-deck');
|
||||||
|
} else {
|
||||||
|
childArgs.push('--stats-word-helper-word', options.word ?? '');
|
||||||
|
}
|
||||||
const logLevel = readFlagValue(process.argv, '--log-level');
|
const logLevel = readFlagValue(process.argv, '--log-level');
|
||||||
if (logLevel) {
|
if (logLevel) {
|
||||||
childArgs.push('--log-level', logLevel);
|
childArgs.push('--log-level', logLevel);
|
||||||
@@ -88,10 +93,13 @@ const invokeStatsWordHelper = createInvokeStatsWordHelperHandler({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
waitForResponse: waitForWordHelperResponse,
|
waitForResponse: waitForWordHelperResponse,
|
||||||
removeDir: (targetPath) => {
|
removeDir: (targetPath: string) => {
|
||||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const invokeStatsWordHelper = createInvokeStatsWordHelperHandler(statsWordHelperDeps);
|
||||||
|
const readStatsYomitanDeckName = createReadStatsYomitanDeckNameHandler(statsWordHelperDeps);
|
||||||
|
|
||||||
const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim();
|
const userDataPath = readFlagValue(process.argv, '--stats-user-data-path')?.trim();
|
||||||
const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim();
|
const responsePath = readFlagValue(process.argv, '--stats-response-path')?.trim();
|
||||||
@@ -195,7 +203,15 @@ async function main(): Promise<void> {
|
|||||||
staticDir: statsDistPath,
|
staticDir: statsDistPath,
|
||||||
tracker,
|
tracker,
|
||||||
knownWordCachePath,
|
knownWordCachePath,
|
||||||
ankiConnectConfig: config.ankiConnect,
|
getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
|
||||||
|
getYomitanAnkiDeckName: async () =>
|
||||||
|
await readStatsYomitanDeckName({
|
||||||
|
helperScriptPath: wordHelperScriptPath,
|
||||||
|
userDataPath: daemonUserDataPath,
|
||||||
|
}),
|
||||||
|
getSecondarySubtitleLanguages: () =>
|
||||||
|
configService.reloadConfig().secondarySub.secondarySubLanguages,
|
||||||
|
getStatsMiningAlassPath: () => configService.reloadConfig().subsync.alass_path,
|
||||||
addYomitanNote: async (word: string) =>
|
addYomitanNote: async (word: string) =>
|
||||||
await invokeStatsWordHelper({
|
await invokeStatsWordHelper({
|
||||||
helperScriptPath: wordHelperScriptPath,
|
helperScriptPath: wordHelperScriptPath,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createInvokeStatsWordHelperHandler } from './stats-word-helper-client';
|
import {
|
||||||
|
createInvokeStatsWordHelperHandler,
|
||||||
|
createReadStatsYomitanDeckNameHandler,
|
||||||
|
} from './stats-word-helper-client';
|
||||||
|
|
||||||
test('word helper client returns note id when helper responds before exit', async () => {
|
test('word helper client returns note id when helper responds before exit', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -36,6 +39,39 @@ test('word helper client returns note id when helper responds before exit', asyn
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('word helper client returns Yomitan deck name from helper read-deck mode', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createReadStatsYomitanDeckNameHandler({
|
||||||
|
createTempDir: () => '/tmp/stats-word-helper',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
spawnHelper: async (options) => {
|
||||||
|
calls.push(
|
||||||
|
`spawnHelper:${options.scriptPath}:${options.responsePath}:${options.userDataPath}:${options.mode}`,
|
||||||
|
);
|
||||||
|
return new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
||||||
|
},
|
||||||
|
waitForResponse: async (responsePath) => {
|
||||||
|
calls.push(`waitForResponse:${responsePath}`);
|
||||||
|
return { ok: true, deckName: ' Minecraft ' };
|
||||||
|
},
|
||||||
|
removeDir: (targetPath) => {
|
||||||
|
calls.push(`removeDir:${targetPath}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deckName = await handler({
|
||||||
|
helperScriptPath: '/tmp/stats-word-helper.js',
|
||||||
|
userDataPath: '/tmp/SubMiner',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(deckName, 'Minecraft');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'spawnHelper:/tmp/stats-word-helper.js:/tmp/stats-word-helper/response.json:/tmp/SubMiner:deck-name',
|
||||||
|
'waitForResponse:/tmp/stats-word-helper/response.json',
|
||||||
|
'removeDir:/tmp/stats-word-helper',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('word helper client throws helper response errors', async () => {
|
test('word helper client throws helper response errors', async () => {
|
||||||
const handler = createInvokeStatsWordHelperHandler({
|
const handler = createInvokeStatsWordHelperHandler({
|
||||||
createTempDir: () => '/tmp/stats-word-helper',
|
createTempDir: () => '/tmp/stats-word-helper',
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
export type StatsWordHelperResponse = {
|
export type StatsWordHelperResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
noteId?: number;
|
noteId?: number;
|
||||||
|
deckName?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StatsWordHelperMode = 'add-word' | 'deck-name';
|
||||||
|
|
||||||
|
export type StatsWordHelperSpawnOptions = {
|
||||||
|
scriptPath: string;
|
||||||
|
responsePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
mode: StatsWordHelperMode;
|
||||||
|
word?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function createInvokeStatsWordHelperHandler(deps: {
|
export function createInvokeStatsWordHelperHandler(deps: {
|
||||||
createTempDir: (prefix: string) => string;
|
createTempDir: (prefix: string) => string;
|
||||||
joinPath: (...parts: string[]) => string;
|
joinPath: (...parts: string[]) => string;
|
||||||
spawnHelper: (options: {
|
spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
|
||||||
scriptPath: string;
|
|
||||||
responsePath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
word: string;
|
|
||||||
}) => Promise<number>;
|
|
||||||
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
|
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
|
||||||
removeDir: (targetPath: string) => void;
|
removeDir: (targetPath: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -29,6 +35,7 @@ export function createInvokeStatsWordHelperHandler(deps: {
|
|||||||
scriptPath: options.helperScriptPath,
|
scriptPath: options.helperScriptPath,
|
||||||
responsePath,
|
responsePath,
|
||||||
userDataPath: options.userDataPath,
|
userDataPath: options.userDataPath,
|
||||||
|
mode: 'add-word',
|
||||||
word: options.word,
|
word: options.word,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,3 +71,55 @@ export function createInvokeStatsWordHelperHandler(deps: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createReadStatsYomitanDeckNameHandler(deps: {
|
||||||
|
createTempDir: (prefix: string) => string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
spawnHelper: (options: StatsWordHelperSpawnOptions) => Promise<number>;
|
||||||
|
waitForResponse: (responsePath: string) => Promise<StatsWordHelperResponse>;
|
||||||
|
removeDir: (targetPath: string) => void;
|
||||||
|
}) {
|
||||||
|
return async (options: { helperScriptPath: string; userDataPath: string }): Promise<string> => {
|
||||||
|
const tempDir = deps.createTempDir('subminer-stats-word-helper-');
|
||||||
|
const responsePath = deps.joinPath(tempDir, 'response.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const helperExitPromise = deps.spawnHelper({
|
||||||
|
scriptPath: options.helperScriptPath,
|
||||||
|
responsePath,
|
||||||
|
userDataPath: options.userDataPath,
|
||||||
|
mode: 'deck-name',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startupResult = await Promise.race([
|
||||||
|
deps
|
||||||
|
.waitForResponse(responsePath)
|
||||||
|
.then((response) => ({ kind: 'response' as const, response })),
|
||||||
|
helperExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let response: StatsWordHelperResponse;
|
||||||
|
if (startupResult.kind === 'response') {
|
||||||
|
response = startupResult.response;
|
||||||
|
} else {
|
||||||
|
if (startupResult.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Stats word helper exited before response (status ${startupResult.status}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response = await deps.waitForResponse(responsePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitStatus = await helperExitPromise;
|
||||||
|
if (exitStatus !== 0) {
|
||||||
|
throw new Error(`Stats word helper exited with status ${exitStatus}.`);
|
||||||
|
}
|
||||||
|
if (!response.ok || typeof response.deckName !== 'string') {
|
||||||
|
throw new Error(response.error || 'Stats word helper failed.');
|
||||||
|
}
|
||||||
|
return response.deckName.trim();
|
||||||
|
} finally {
|
||||||
|
deps.removeDir(tempDir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+34
-38
@@ -7,6 +7,7 @@ import { createLogger, setLogLevel } from './logger';
|
|||||||
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
|
import { loadYomitanExtension } from './core/services/yomitan-extension-loader';
|
||||||
import {
|
import {
|
||||||
addYomitanNoteViaSearch,
|
addYomitanNoteViaSearch,
|
||||||
|
getYomitanCurrentAnkiDeckName,
|
||||||
syncYomitanDefaultAnkiServer,
|
syncYomitanDefaultAnkiServer,
|
||||||
} from './core/services/tokenizer/yomitan-parser-runtime';
|
} from './core/services/tokenizer/yomitan-parser-runtime';
|
||||||
import type { StatsWordHelperResponse } from './stats-word-helper-client';
|
import type { StatsWordHelperResponse } from './stats-word-helper-client';
|
||||||
@@ -54,13 +55,14 @@ function writeResponse(responsePath: string | undefined, payload: StatsWordHelpe
|
|||||||
const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim();
|
const responsePath = readFlagValue(process.argv, '--stats-word-helper-response-path')?.trim();
|
||||||
const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim();
|
const userDataPath = readFlagValue(process.argv, '--stats-word-helper-user-data-path')?.trim();
|
||||||
const word = readFlagValue(process.argv, '--stats-word-helper-word');
|
const word = readFlagValue(process.argv, '--stats-word-helper-word');
|
||||||
|
const readDeck = process.argv.includes('--stats-word-helper-read-deck');
|
||||||
const logLevel = readFlagValue(process.argv, '--log-level');
|
const logLevel = readFlagValue(process.argv, '--log-level');
|
||||||
|
|
||||||
if (logLevel) {
|
if (logLevel) {
|
||||||
setLogLevel(logLevel, 'cli');
|
setLogLevel(logLevel, 'cli');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userDataPath || !word) {
|
if (!userDataPath || (!word && !readDeck)) {
|
||||||
writeResponse(responsePath, {
|
writeResponse(responsePath, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'Missing stats word helper arguments.',
|
error: 'Missing stats word helper arguments.',
|
||||||
@@ -125,48 +127,42 @@ async function main(): Promise<void> {
|
|||||||
throw new Error('Yomitan extension failed to load.');
|
throw new Error('Yomitan extension failed to load.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const yomitanDeps = {
|
||||||
|
getYomitanExt: () => yomitanExt,
|
||||||
|
getYomitanSession: () => yomitanSession,
|
||||||
|
getYomitanParserWindow: () => yomitanParserWindow,
|
||||||
|
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||||
|
yomitanParserWindow = window;
|
||||||
|
},
|
||||||
|
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
|
||||||
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
|
||||||
|
yomitanParserReadyPromise = promise;
|
||||||
|
},
|
||||||
|
getYomitanParserInitPromise: () => yomitanParserInitPromise,
|
||||||
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
|
||||||
|
yomitanParserInitPromise = promise;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (readDeck) {
|
||||||
|
const deckName = await getYomitanCurrentAnkiDeckName(yomitanDeps, logger);
|
||||||
|
writeResponse(responsePath, {
|
||||||
|
ok: true,
|
||||||
|
deckName,
|
||||||
|
});
|
||||||
|
cleanup();
|
||||||
|
app.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await syncYomitanDefaultAnkiServer(
|
await syncYomitanDefaultAnkiServer(
|
||||||
config.ankiConnect?.url || 'http://127.0.0.1:8765',
|
config.ankiConnect?.url || 'http://127.0.0.1:8765',
|
||||||
{
|
yomitanDeps,
|
||||||
getYomitanExt: () => yomitanExt,
|
|
||||||
getYomitanSession: () => yomitanSession,
|
|
||||||
getYomitanParserWindow: () => yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger,
|
logger,
|
||||||
{ forceOverride: true },
|
{ forceOverride: true, deck: config.ankiConnect?.deck },
|
||||||
);
|
);
|
||||||
|
|
||||||
const addResult = await addYomitanNoteViaSearch(
|
const addResult = await addYomitanNoteViaSearch(word!, yomitanDeps, logger);
|
||||||
word!,
|
|
||||||
{
|
|
||||||
getYomitanExt: () => yomitanExt,
|
|
||||||
getYomitanSession: () => yomitanSession,
|
|
||||||
getYomitanParserWindow: () => yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
|
|
||||||
const noteId = addResult.noteId;
|
const noteId = addResult.noteId;
|
||||||
if (typeof noteId !== 'number') {
|
if (typeof noteId !== 'number') {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ const VocabularyTab = lazy(() =>
|
|||||||
default: module.VocabularyTab,
|
default: module.VocabularyTab,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const SearchTab = lazy(() =>
|
||||||
|
import('./components/search/SearchTab').then((module) => ({
|
||||||
|
default: module.SearchTab,
|
||||||
|
})),
|
||||||
|
);
|
||||||
const SessionsTab = lazy(() =>
|
const SessionsTab = lazy(() =>
|
||||||
import('./components/sessions/SessionsTab').then((module) => ({
|
import('./components/sessions/SessionsTab').then((module) => ({
|
||||||
default: module.SessionsTab,
|
default: module.SessionsTab,
|
||||||
@@ -183,6 +188,7 @@ export function App() {
|
|||||||
<OverviewTab
|
<OverviewTab
|
||||||
onNavigateToMediaDetail={navigateToOverviewMediaDetail}
|
onNavigateToMediaDetail={navigateToOverviewMediaDetail}
|
||||||
onNavigateToSession={navigateToSession}
|
onNavigateToSession={navigateToSession}
|
||||||
|
isActive={activeTab === 'overview'}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -239,6 +245,19 @@ export function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
{mountedTabs.has('search') ? (
|
||||||
|
<section
|
||||||
|
id="panel-search"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-search"
|
||||||
|
hidden={activeTab !== 'search'}
|
||||||
|
className="animate-fade-in"
|
||||||
|
>
|
||||||
|
<Suspense fallback={<LoadingSurface label="Loading search..." />}>
|
||||||
|
<SearchTab />
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
{mountedTabs.has('sessions') ? (
|
{mountedTabs.has('sessions') ? (
|
||||||
<section
|
<section
|
||||||
id="panel-sessions"
|
id="panel-sessions"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function AnilistSelector({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [linking, setLinking] = useState<number | null>(null);
|
const [linking, setLinking] = useState<number | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -53,7 +53,7 @@ export function AnilistSelector({
|
|||||||
|
|
||||||
const handleInput = (value: string) => {
|
const handleInput = (value: string) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { RetryingCoverImage } from '../common/RetryingCoverImage';
|
||||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
|
|
||||||
interface AnimeCoverImageProps {
|
interface AnimeCoverImageProps {
|
||||||
@@ -8,28 +8,7 @@ interface AnimeCoverImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const fallbackChar = title.charAt(0) || '?';
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
|
||||||
>
|
|
||||||
{fallbackChar}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||||
|
|
||||||
return (
|
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
||||||
import { formatDuration } from '../../lib/formatters';
|
import { formatDuration } from '../../lib/formatters';
|
||||||
|
import {
|
||||||
|
getLibraryCardSizeStorage,
|
||||||
|
readLibraryCardSizePreference,
|
||||||
|
type LibraryCardSize,
|
||||||
|
writeLibraryCardSizePreference,
|
||||||
|
} from '../../lib/library-card-size';
|
||||||
import { AnimeCard } from './AnimeCard';
|
import { AnimeCard } from './AnimeCard';
|
||||||
import { AnimeDetailView } from './AnimeDetailView';
|
import { AnimeDetailView } from './AnimeDetailView';
|
||||||
|
|
||||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||||
type CardSize = 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
const GRID_CLASSES: Record<CardSize, string> = {
|
const GRID_CLASSES: Record<LibraryCardSize, string> = {
|
||||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||||
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
||||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||||
@@ -51,9 +56,21 @@ export function AnimeTab({
|
|||||||
const { anime, loading, error } = useAnimeLibrary();
|
const { anime, loading, error } = useAnimeLibrary();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||||
const [cardSize, setCardSize] = useState<CardSize>('md');
|
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||||
|
readLibraryCardSizePreference(
|
||||||
|
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
|
||||||
|
),
|
||||||
|
);
|
||||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function handleCardSizeChange(size: LibraryCardSize): void {
|
||||||
|
setCardSize(size);
|
||||||
|
writeLibraryCardSizePreference(
|
||||||
|
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialAnimeId != null) {
|
if (initialAnimeId != null) {
|
||||||
setSelectedAnimeId(initialAnimeId);
|
setSelectedAnimeId(initialAnimeId);
|
||||||
@@ -113,7 +130,7 @@ export function AnimeTab({
|
|||||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||||
<button
|
<button
|
||||||
key={size}
|
key={size}
|
||||||
onClick={() => setCardSize(size)}
|
onClick={() => handleCardSizeChange(size)}
|
||||||
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
||||||
cardSize === size
|
cardSize === size
|
||||||
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
interface DeleteProgressToastProps {
|
||||||
|
/** Number of sessions currently being deleted. The toast is hidden when 0. */
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed-position toast shown while session deletions are in flight.
|
||||||
|
*
|
||||||
|
* The per-row delete buttons are only visible on hover, so once the confirm
|
||||||
|
* dialog closes the user has no signal that a (potentially slow) batch delete
|
||||||
|
* is still running. This stays on screen, independent of hover, until the work
|
||||||
|
* finishes.
|
||||||
|
*/
|
||||||
|
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-ctp-text">
|
||||||
|
Deleting {count} session{count === 1 ? '' : 's'}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
|
||||||
|
|
||||||
|
interface RetryingCoverImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
className?: string;
|
||||||
|
fallbackTextClassName?: string;
|
||||||
|
loading?: 'eager' | 'lazy';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetryingCoverImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
fallbackLabel,
|
||||||
|
className = '',
|
||||||
|
fallbackTextClassName = 'text-2xl',
|
||||||
|
loading = 'lazy',
|
||||||
|
}: RetryingCoverImageProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const fallbackChar = fallbackLabel.charAt(0) || '?';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(false);
|
||||||
|
setRetryToken(0);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!failed) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setRetryToken((value) => value + 1);
|
||||||
|
setFailed(false);
|
||||||
|
}, getCoverRetryDelayMs(retryToken));
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [failed, retryToken]);
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
|
||||||
|
>
|
||||||
|
{fallbackChar}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={appendCoverRetryToken(src, retryToken)}
|
||||||
|
alt={alt}
|
||||||
|
loading={loading}
|
||||||
|
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
onLoad={() => setFailed(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, type KeyboardEvent } from 'react';
|
import { useRef, type KeyboardEvent } from 'react';
|
||||||
|
|
||||||
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
|
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'search' | 'sessions';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -12,6 +12,7 @@ const TABS: Tab[] = [
|
|||||||
{ id: 'anime', label: 'Library' },
|
{ id: 'anime', label: 'Library' },
|
||||||
{ id: 'trends', label: 'Trends' },
|
{ id: 'trends', label: 'Trends' },
|
||||||
{ id: 'vocabulary', label: 'Vocabulary' },
|
{ id: 'vocabulary', label: 'Vocabulary' },
|
||||||
|
{ id: 'search', label: 'Search' },
|
||||||
{ id: 'sessions', label: 'Sessions' },
|
{ id: 'sessions', label: 'Sessions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { RetryingCoverImage } from '../common/RetryingCoverImage';
|
||||||
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
||||||
|
|
||||||
interface CoverImageProps {
|
interface CoverImageProps {
|
||||||
@@ -9,31 +9,9 @@ interface CoverImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
|
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
const fallbackChar = title.charAt(0) || '?';
|
|
||||||
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
|
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFailed(false);
|
|
||||||
}, [resolvedSrc]);
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
|
||||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
|
||||||
>
|
|
||||||
{fallbackChar}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={resolvedSrc}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
|
||||||
|
|
||||||
|
interface CoverThumbnailProps {
|
||||||
|
animeId: number | null;
|
||||||
|
videoId: number | null;
|
||||||
|
title: string;
|
||||||
|
coverImages: CoverImageMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const fallbackChar = title.charAt(0) || '?';
|
||||||
|
const fallback = (
|
||||||
|
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
||||||
|
{fallbackChar}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const src =
|
||||||
|
animeId != null
|
||||||
|
? getCoverImageSrc(coverImages, 'anime', animeId)
|
||||||
|
: getCoverImageSrc(coverImages, 'media', videoId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailed(false);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
if (!src || failed) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
|
|||||||
import { RecentSessions } from './RecentSessions';
|
import { RecentSessions } from './RecentSessions';
|
||||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||||
import { TrendChart } from '../trends/TrendChart';
|
import { TrendChart } from '../trends/TrendChart';
|
||||||
|
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
@@ -19,9 +20,14 @@ import type { SessionSummary } from '../../types/stats';
|
|||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||||
onNavigateToSession: (sessionId: number) => void;
|
onNavigateToSession: (sessionId: number) => void;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: OverviewTabProps) {
|
export function OverviewTab({
|
||||||
|
onNavigateToMediaDetail,
|
||||||
|
onNavigateToSession,
|
||||||
|
isActive = true,
|
||||||
|
}: OverviewTabProps) {
|
||||||
const { data, sessions, setSessions, loading, error } = useOverview();
|
const { data, sessions, setSessions, loading, error } = useOverview();
|
||||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
@@ -152,7 +158,10 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
onDeleteDayGroup={handleDeleteDayGroup}
|
onDeleteDayGroup={handleDeleteDayGroup}
|
||||||
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
||||||
deletingIds={deletingIds}
|
deletingIds={deletingIds}
|
||||||
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteProgressToast count={deletingIds.size} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
formatSessionDayLabel,
|
formatSessionDayLabel,
|
||||||
} from '../../lib/formatters';
|
} from '../../lib/formatters';
|
||||||
import { BASE_URL } from '../../lib/api-client';
|
import { CoverThumbnail } from './CoverThumbnail';
|
||||||
|
import { useCoverImages } from '../../hooks/useCoverImages';
|
||||||
|
import type { CoverImageMap } from '../../lib/cover-images';
|
||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { SessionSummary } from '../../types/stats';
|
||||||
@@ -18,6 +20,7 @@ interface RecentSessionsProps {
|
|||||||
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
|
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
|
||||||
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
|
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
|
||||||
deletingIds: Set<number>;
|
deletingIds: Set<number>;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimeGroup {
|
interface AnimeGroup {
|
||||||
@@ -85,53 +88,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoverThumbnail({
|
|
||||||
animeId,
|
|
||||||
videoId,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
animeId: number | null;
|
|
||||||
videoId: number | null;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const fallbackChar = title.charAt(0) || '?';
|
|
||||||
const [isFallback, setIsFallback] = useState(false);
|
|
||||||
|
|
||||||
if ((!animeId && !videoId) || isFallback) {
|
|
||||||
return (
|
|
||||||
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
|
||||||
{fallbackChar}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const src =
|
|
||||||
animeId != null
|
|
||||||
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
|
|
||||||
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt=""
|
|
||||||
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
|
||||||
onError={() => setIsFallback(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionItem({
|
function SessionItem({
|
||||||
session,
|
session,
|
||||||
onNavigateToMediaDetail,
|
onNavigateToMediaDetail,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onDelete,
|
onDelete,
|
||||||
deleteDisabled,
|
deleteDisabled,
|
||||||
|
coverImages,
|
||||||
}: {
|
}: {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||||
onNavigateToSession: (sessionId: number) => void;
|
onNavigateToSession: (sessionId: number) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
deleteDisabled: boolean;
|
deleteDisabled: boolean;
|
||||||
|
coverImages: CoverImageMap;
|
||||||
}) {
|
}) {
|
||||||
const displayWordCount = getSessionDisplayWordCount(session);
|
const displayWordCount = getSessionDisplayWordCount(session);
|
||||||
const navigationTarget = getSessionNavigationTarget(session);
|
const navigationTarget = getSessionNavigationTarget(session);
|
||||||
@@ -153,6 +123,7 @@ function SessionItem({
|
|||||||
animeId={session.animeId}
|
animeId={session.animeId}
|
||||||
videoId={session.videoId}
|
videoId={session.videoId}
|
||||||
title={session.canonicalTitle ?? 'Unknown'}
|
title={session.canonicalTitle ?? 'Unknown'}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">
|
<div className="text-sm font-medium text-ctp-text truncate">
|
||||||
@@ -205,6 +176,7 @@ function AnimeGroupRow({
|
|||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onDeleteAnimeGroup,
|
onDeleteAnimeGroup,
|
||||||
deletingIds,
|
deletingIds,
|
||||||
|
coverImages,
|
||||||
}: {
|
}: {
|
||||||
group: AnimeGroup;
|
group: AnimeGroup;
|
||||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||||
@@ -212,6 +184,7 @@ function AnimeGroupRow({
|
|||||||
onDeleteSession: (session: SessionSummary) => void;
|
onDeleteSession: (session: SessionSummary) => void;
|
||||||
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
||||||
deletingIds: Set<number>;
|
deletingIds: Set<number>;
|
||||||
|
coverImages: CoverImageMap;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
||||||
@@ -225,6 +198,7 @@ function AnimeGroupRow({
|
|||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onDelete={() => onDeleteSession(s)}
|
onDelete={() => onDeleteSession(s)}
|
||||||
deleteDisabled={deletingIds.has(s.sessionId)}
|
deleteDisabled={deletingIds.has(s.sessionId)}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,6 +221,7 @@ function AnimeGroupRow({
|
|||||||
animeId={group.animeId}
|
animeId={group.animeId}
|
||||||
videoId={mostRecentSession.videoId}
|
videoId={mostRecentSession.videoId}
|
||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||||
@@ -319,6 +294,7 @@ function AnimeGroupRow({
|
|||||||
animeId={s.animeId}
|
animeId={s.animeId}
|
||||||
videoId={s.videoId}
|
videoId={s.videoId}
|
||||||
title={s.canonicalTitle ?? 'Unknown'}
|
title={s.canonicalTitle ?? 'Unknown'}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||||
@@ -377,7 +353,10 @@ export function RecentSessions({
|
|||||||
onDeleteDayGroup,
|
onDeleteDayGroup,
|
||||||
onDeleteAnimeGroup,
|
onDeleteAnimeGroup,
|
||||||
deletingIds,
|
deletingIds,
|
||||||
|
isActive = true,
|
||||||
}: RecentSessionsProps) {
|
}: RecentSessionsProps) {
|
||||||
|
const coverImages = useCoverImages(sessions, { enabled: isActive });
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
@@ -422,6 +401,7 @@ export function RecentSessions({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
||||||
deletingIds={deletingIds}
|
deletingIds={deletingIds}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { formatSentenceSearchMatchCountLabel } from './SearchTab';
|
||||||
|
|
||||||
|
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
|
||||||
|
|
||||||
|
test('formatSentenceSearchMatchCountLabel uses singular label for one result', () => {
|
||||||
|
assert.equal(formatSentenceSearchMatchCountLabel(1), 'Match');
|
||||||
|
assert.equal(formatSentenceSearchMatchCountLabel(0), 'Matches');
|
||||||
|
assert.equal(formatSentenceSearchMatchCountLabel(2), 'Matches');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
|
||||||
|
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /buildStatsMineCardParams\(result,\s*searchedWord,\s*mode\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SearchTab enables headword sentence search by default and forwards the toggle', () => {
|
||||||
|
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /const \[searchByHeadword,\s*setSearchByHeadword\] = useState\(true\);/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/apiClient\s*\.\s*searchSentences\(trimmed,\s*SEARCH_LIMIT,\s*searchByHeadword\)/,
|
||||||
|
);
|
||||||
|
assert.match(source, /checked=\{searchByHeadword\}/);
|
||||||
|
assert.match(source, /setSearchByHeadword\(event\.target\.checked\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { apiClient } from '../../lib/api-client';
|
||||||
|
import {
|
||||||
|
getSentenceSearchMineAvailability,
|
||||||
|
renderSentenceWithMatches,
|
||||||
|
} from '../../lib/sentence-search';
|
||||||
|
import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining';
|
||||||
|
import type { SentenceSearchResult } from '../../types/stats';
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 50;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 160;
|
||||||
|
|
||||||
|
type MineMode = 'word' | 'sentence' | 'audio';
|
||||||
|
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
|
||||||
|
|
||||||
|
function formatSegment(ms: number | null): string {
|
||||||
|
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultKey(result: SentenceSearchResult, index: number): string {
|
||||||
|
return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string {
|
||||||
|
return `${resultKey(result, index)}-${mode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonLabel(
|
||||||
|
mode: MineMode,
|
||||||
|
status: MineStatus | undefined,
|
||||||
|
disabledLabel: string,
|
||||||
|
): string {
|
||||||
|
if (status?.loading) return 'Mining...';
|
||||||
|
if (status?.success) return 'Mined!';
|
||||||
|
if (disabledLabel) return disabledLabel;
|
||||||
|
if (mode === 'word') return 'Word';
|
||||||
|
if (mode === 'audio') return 'Audio';
|
||||||
|
return 'Sentence';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSentenceSearchMatchCountLabel(count: number): string {
|
||||||
|
return count === 1 ? 'Match' : 'Matches';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchTab() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [searchByHeadword, setSearchByHeadword] = useState(true);
|
||||||
|
const [results, setResults] = useState<SentenceSearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
|
||||||
|
const requestRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const requestId = ++requestRef.current;
|
||||||
|
setMineStatus({});
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
setResults([]);
|
||||||
|
setLoading(false);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
apiClient
|
||||||
|
.searchSentences(trimmed, SEARCH_LIMIT, searchByHeadword)
|
||||||
|
.then((nextResults) => {
|
||||||
|
if (requestId !== requestRef.current) return;
|
||||||
|
setResults(nextResults);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (requestId !== requestRef.current) return;
|
||||||
|
setError(err.message);
|
||||||
|
setResults([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestId !== requestRef.current) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [query, searchByHeadword]);
|
||||||
|
|
||||||
|
const handleMine = async (
|
||||||
|
result: SentenceSearchResult,
|
||||||
|
index: number,
|
||||||
|
mode: MineMode,
|
||||||
|
): Promise<void> => {
|
||||||
|
const availability = getSentenceSearchMineAvailability(result, query);
|
||||||
|
if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const searchedWord = availability.exactMatch ? query.trim() : '';
|
||||||
|
const params = buildStatsMineCardParams(result, searchedWord, mode);
|
||||||
|
if (!params) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = statusKey(result, index, mode);
|
||||||
|
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||||
|
try {
|
||||||
|
const response = await apiClient.mineCard(params);
|
||||||
|
const responseError = getStatsMineCardError(response);
|
||||||
|
if (responseError) {
|
||||||
|
setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
||||||
|
} catch (err) {
|
||||||
|
setMineStatus((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: { error: err instanceof Error ? err.message : String(err) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
|
||||||
|
<span className="mb-3 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||||
|
Sentence Search
|
||||||
|
</span>
|
||||||
|
<div className="flex items-stretch gap-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search sentence text or media..."
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label="Sentence search"
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-[5rem] flex-col items-center justify-center rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2">
|
||||||
|
<div className="text-xl font-semibold leading-none text-ctp-yellow">
|
||||||
|
{loading ? '...' : results.length}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] uppercase tracking-wide text-ctp-overlay1">
|
||||||
|
{loading ? 'Matches' : formatSentenceSearchMatchCountLabel(results.length)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 inline-flex cursor-pointer items-center gap-2 text-xs font-medium text-ctp-overlay1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchByHeadword}
|
||||||
|
onChange={(event) => setSearchByHeadword(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-ctp-surface2 bg-ctp-surface0 text-ctp-yellow focus:ring-ctp-yellow"
|
||||||
|
/>
|
||||||
|
Search by headword
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-ctp-red/40 bg-ctp-red/10 p-3 text-sm text-ctp-red">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!trimmedQuery && (
|
||||||
|
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
|
||||||
|
Search your tracked subtitle lines.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trimmedQuery && !loading && !error && results.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
|
||||||
|
No sentence matches.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{results.map((result, index) => {
|
||||||
|
const availability = getSentenceSearchMineAvailability(result, trimmedQuery);
|
||||||
|
const wordStatus = mineStatus[statusKey(result, index, 'word')];
|
||||||
|
const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')];
|
||||||
|
const audioStatus = mineStatus[statusKey(result, index, 'audio')];
|
||||||
|
const wordAudioDisabledReason =
|
||||||
|
availability.unavailableReason ??
|
||||||
|
(availability.exactMatch ? '' : 'Exact searched word not found in sentence.');
|
||||||
|
const sentenceDisabledReason = availability.unavailableReason ?? '';
|
||||||
|
const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={resultKey(result, index)}
|
||||||
|
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/90 p-4 shadow-lg shadow-ctp-crust/20"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||||
|
{result.animeTitle ?? result.videoTitle}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-ctp-overlay1">
|
||||||
|
<span className="max-w-full truncate">{result.videoTitle}</span>
|
||||||
|
<span>line {result.lineIndex}</span>
|
||||||
|
<span>session {result.sessionId}</span>
|
||||||
|
<span>
|
||||||
|
{formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availability.exactMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
|
||||||
|
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||||
|
disabled={wordStatus?.loading || !availability.canMineWordAudio}
|
||||||
|
onClick={() => void handleMine(result, index, 'word')}
|
||||||
|
>
|
||||||
|
{buttonLabel(
|
||||||
|
'word',
|
||||||
|
wordStatus,
|
||||||
|
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={sentenceDisabledReason || 'Create a sentence card from this line'}
|
||||||
|
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||||
|
disabled={sentenceStatus?.loading || !availability.canMineSentence}
|
||||||
|
onClick={() => void handleMine(result, index, 'sentence')}
|
||||||
|
>
|
||||||
|
{buttonLabel(
|
||||||
|
'sentence',
|
||||||
|
sentenceStatus,
|
||||||
|
availability.canMineSentence ? '' : 'Unavailable',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{availability.exactMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
|
||||||
|
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
|
||||||
|
disabled={audioStatus?.loading || !availability.canMineWordAudio}
|
||||||
|
onClick={() => void handleMine(result, index, 'audio')}
|
||||||
|
>
|
||||||
|
{buttonLabel(
|
||||||
|
'audio',
|
||||||
|
audioStatus,
|
||||||
|
availability.canMineWordAudio ? '' : 'Unavailable',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
|
||||||
|
{renderSentenceWithMatches(result.text, trimmedQuery)}
|
||||||
|
</p>
|
||||||
|
{errors.length > 0 && <div className="mt-2 text-xs text-ctp-red">{errors[0]}</div>}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,66 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
|
|||||||
assert.equal(onErrorCalled, false);
|
assert.equal(onErrorCalled, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildBucketDeleteHandler signals deleted session IDs after confirm, before deleting', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let startedIds: number[] | null = null;
|
||||||
|
|
||||||
|
const bucket = makeBucket([
|
||||||
|
makeSession({ sessionId: 11 }),
|
||||||
|
makeSession({ sessionId: 22 }),
|
||||||
|
makeSession({ sessionId: 33 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handler = buildBucketDeleteHandler({
|
||||||
|
bucket,
|
||||||
|
apiClient: {
|
||||||
|
deleteSessions: async () => {
|
||||||
|
events.push('delete');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confirm: () => {
|
||||||
|
events.push('confirm');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onStart: (ids) => {
|
||||||
|
startedIds = ids;
|
||||||
|
events.push('start');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
events.push('success');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
events.push('error');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler();
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['confirm', 'start', 'delete', 'success']);
|
||||||
|
assert.deepEqual(startedIds, [11, 22, 33]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
|
||||||
|
let startCalled = false;
|
||||||
|
|
||||||
|
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||||
|
|
||||||
|
const handler = buildBucketDeleteHandler({
|
||||||
|
bucket,
|
||||||
|
apiClient: { deleteSessions: async () => {} },
|
||||||
|
confirm: () => false,
|
||||||
|
onStart: () => {
|
||||||
|
startCalled = true;
|
||||||
|
},
|
||||||
|
onSuccess: () => {},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler();
|
||||||
|
|
||||||
|
assert.equal(startCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
let successCalled = false;
|
let successCalled = false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useSessions } from '../../hooks/useSessions';
|
import { useSessions } from '../../hooks/useSessions';
|
||||||
import { SessionRow } from './SessionRow';
|
import { SessionRow } from './SessionRow';
|
||||||
import { SessionDetail } from './SessionDetail';
|
import { SessionDetail } from './SessionDetail';
|
||||||
|
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||||
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
|
|||||||
bucket: SessionBucket;
|
bucket: SessionBucket;
|
||||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||||
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
||||||
|
/** Called once confirmation passes, just before the delete request begins. */
|
||||||
|
onStart?: (sessionIds: number[]) => void;
|
||||||
onSuccess: (deletedIds: number[]) => void;
|
onSuccess: (deletedIds: number[]) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
|
|||||||
* rendering the full SessionsTab or mocking React state.
|
* rendering the full SessionsTab or mocking React state.
|
||||||
*/
|
*/
|
||||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
||||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
|
||||||
return async () => {
|
return async () => {
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||||
try {
|
try {
|
||||||
if (!(await confirm(title, ids.length))) return;
|
if (!(await confirm(title, ids.length))) return;
|
||||||
|
onStart?.(ids);
|
||||||
await client.deleteSessions(ids);
|
await client.deleteSessions(ids);
|
||||||
onSuccess(ids);
|
onSuccess(ids);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -70,7 +74,7 @@ export function SessionsTab({
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionIds, setDeletingSessionIds] = useState<Set<number>>(() => new Set());
|
||||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,7 +134,11 @@ export function SessionsTab({
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(session.sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await apiClient.deleteSession(session.sessionId);
|
await apiClient.deleteSession(session.sessionId);
|
||||||
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
|
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
|
||||||
@@ -138,7 +146,11 @@ export function SessionsTab({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingSessionId(null);
|
setDeletingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(session.sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +161,12 @@ export function SessionsTab({
|
|||||||
bucket,
|
bucket,
|
||||||
apiClient,
|
apiClient,
|
||||||
confirm: confirmBucketDelete,
|
confirm: confirmBucketDelete,
|
||||||
|
onStart: (ids) =>
|
||||||
|
setDeletingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of ids) next.add(id);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
onSuccess: (ids) => {
|
onSuccess: (ids) => {
|
||||||
const deleted = new Set(ids);
|
const deleted = new Set(ids);
|
||||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||||
@@ -166,6 +184,11 @@ export function SessionsTab({
|
|||||||
await handler();
|
await handler();
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingBucketKey(null);
|
setDeletingBucketKey(null);
|
||||||
|
setDeletingSessionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const session of bucket.sessions) next.delete(session.sessionId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,7 +233,7 @@ export function SessionsTab({
|
|||||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||||
}
|
}
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
onDelete={() => void handleDeleteSession(s)}
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
deleteDisabled={deletingSessionIds.has(s.sessionId)}
|
||||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||||
/>
|
/>
|
||||||
{expandedId === s.sessionId && (
|
{expandedId === s.sessionId && (
|
||||||
@@ -279,7 +302,7 @@ export function SessionsTab({
|
|||||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||||
}
|
}
|
||||||
onDelete={() => void handleDeleteSession(s)}
|
onDelete={() => void handleDeleteSession(s)}
|
||||||
deleteDisabled={deletingSessionId === s.sessionId}
|
deleteDisabled={deletingSessionIds.has(s.sessionId)}
|
||||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||||
/>
|
/>
|
||||||
{expandedId === s.sessionId && (
|
{expandedId === s.sessionId && (
|
||||||
@@ -305,6 +328,8 @@ export function SessionsTab({
|
|||||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DeleteProgressToast count={deletingSessionIds.size} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { FrequencyRankTable } from './FrequencyRankTable';
|
import {
|
||||||
|
buildFrequencyRankRows,
|
||||||
|
FrequencyRankTable,
|
||||||
|
isKanaOnlyTokenText,
|
||||||
|
} from './FrequencyRankTable';
|
||||||
import type { VocabularyEntry } from '../../types/stats';
|
import type { VocabularyEntry } from '../../types/stats';
|
||||||
|
|
||||||
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
||||||
@@ -20,6 +24,46 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
|
|||||||
} as VocabularyEntry;
|
} as VocabularyEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||||
|
const values = new Map(Object.entries(initial));
|
||||||
|
const storage = {
|
||||||
|
get length() {
|
||||||
|
return values.size;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
values.clear();
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return values.get(key) ?? null;
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(values.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
} as Storage;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return run();
|
||||||
|
} finally {
|
||||||
|
if (previous) {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', previous);
|
||||||
|
} else {
|
||||||
|
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
|
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
|
||||||
const entry = makeEntry({});
|
const entry = makeEntry({});
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
@@ -41,3 +85,79 @@ test('omits reading when reading equals headword', () => {
|
|||||||
'should not render any bracketed reading when equal to headword',
|
'should not render any bracketed reading when equal to headword',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('identifies kana-only token text without hiding mixed kanji words', () => {
|
||||||
|
assert.equal(isKanaOnlyTokenText('さらに'), true);
|
||||||
|
assert.equal(isKanaOnlyTokenText('バカ'), true);
|
||||||
|
assert.equal(isKanaOnlyTokenText('カレー'), true);
|
||||||
|
assert.equal(isKanaOnlyTokenText('前に'), false);
|
||||||
|
assert.equal(isKanaOnlyTokenText('間違いない'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frequency rows can hide kana-only headwords', () => {
|
||||||
|
const rows = buildFrequencyRankRows(
|
||||||
|
[
|
||||||
|
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
|
||||||
|
makeEntry({
|
||||||
|
wordId: 2,
|
||||||
|
headword: '前に',
|
||||||
|
word: '前に',
|
||||||
|
reading: 'まえに',
|
||||||
|
frequencyRank: 20,
|
||||||
|
}),
|
||||||
|
makeEntry({ wordId: 3, headword: 'バカ', word: 'バカ', reading: 'バカ', frequencyRank: 30 }),
|
||||||
|
],
|
||||||
|
new Set(),
|
||||||
|
{ hideKnown: false, hideKanaOnly: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.headword),
|
||||||
|
['前に'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a Hide Kana filter button', () => {
|
||||||
|
const entry = makeEntry({ headword: 'さらに', word: 'さらに', reading: 'さらに' });
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<FrequencyRankTable words={[entry]} knownWords={new Set()} />,
|
||||||
|
);
|
||||||
|
assert.match(markup, /Hide Kana/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses saved Hide Kana preference on first render', () => {
|
||||||
|
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<FrequencyRankTable
|
||||||
|
words={[
|
||||||
|
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
|
||||||
|
makeEntry({
|
||||||
|
wordId: 2,
|
||||||
|
headword: '前に',
|
||||||
|
word: '前に',
|
||||||
|
reading: 'まえに',
|
||||||
|
frequencyRank: 20,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
knownWords={new Set()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.doesNotMatch(markup, />さらに</);
|
||||||
|
assert.match(markup, />前に</);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses saved Hide Known preference on first render', () => {
|
||||||
|
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<FrequencyRankTable
|
||||||
|
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
|
||||||
|
knownWords={new Set(['日本語'])}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(markup, />Most Common Words Seen</);
|
||||||
|
assert.match(markup, />日本語</);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,22 +10,68 @@ interface FrequencyRankTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
|
||||||
|
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
|
||||||
|
|
||||||
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
interface FrequencyRankOptions {
|
||||||
const [page, setPage] = useState(0);
|
hideKnown: boolean;
|
||||||
const [hideKnown, setHideKnown] = useState(true);
|
hideKanaOnly: boolean;
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
}
|
||||||
|
|
||||||
const hasKnownData = knownWords.size > 0;
|
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u;
|
||||||
|
|
||||||
const isWordKnown = (w: VocabularyEntry): boolean => {
|
export function isKanaOnlyTokenText(text: string): boolean {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWordKnown(w: VocabularyEntry, knownWords: Set<string>): boolean {
|
||||||
return knownWords.has(w.headword) || knownWords.has(w.word);
|
return knownWords.has(w.headword) || knownWords.has(w.word);
|
||||||
};
|
}
|
||||||
|
|
||||||
const ranked = useMemo(() => {
|
function isKanaOnlyWord(w: VocabularyEntry): boolean {
|
||||||
|
return isKanaOnlyTokenText(w.headword || w.word);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferenceStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBooleanPreference(key: string, fallback: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const value = getPreferenceStorage()?.getItem(key);
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBooleanPreference(key: string, value: boolean): void {
|
||||||
|
try {
|
||||||
|
getPreferenceStorage()?.setItem(key, String(value));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFrequencyRankRows(
|
||||||
|
words: VocabularyEntry[],
|
||||||
|
knownWords: Set<string>,
|
||||||
|
options: FrequencyRankOptions,
|
||||||
|
): VocabularyEntry[] {
|
||||||
|
const hasKnownData = knownWords.size > 0;
|
||||||
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
let filtered = words.filter((w) => w.frequencyRank != null && w.frequencyRank > 0);
|
||||||
if (hideKnown && hasKnownData) {
|
if (options.hideKnown && hasKnownData) {
|
||||||
filtered = filtered.filter((w) => !isWordKnown(w));
|
filtered = filtered.filter((w) => !isWordKnown(w, knownWords));
|
||||||
|
}
|
||||||
|
if (options.hideKanaOnly) {
|
||||||
|
filtered = filtered.filter((w) => !isKanaOnlyWord(w));
|
||||||
}
|
}
|
||||||
|
|
||||||
const byHeadword = new Map<string, VocabularyEntry>();
|
const byHeadword = new Map<string, VocabularyEntry>();
|
||||||
@@ -49,7 +95,23 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
return [...byHeadword.values()].sort((a, b) => a.frequencyRank! - b.frequencyRank!);
|
||||||
}, [words, knownWords, hideKnown, hasKnownData]);
|
}
|
||||||
|
|
||||||
|
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [hideKnown, setHideKnown] = useState(() =>
|
||||||
|
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
|
||||||
|
);
|
||||||
|
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
|
||||||
|
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
|
||||||
|
);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const hasKnownData = knownWords.size > 0;
|
||||||
|
|
||||||
|
const ranked = useMemo(() => {
|
||||||
|
return buildFrequencyRankRows(words, knownWords, { hideKnown, hideKanaOnly });
|
||||||
|
}, [words, knownWords, hideKnown, hideKanaOnly]);
|
||||||
|
|
||||||
if (words.every((w) => w.frequencyRank == null)) {
|
if (words.every((w) => w.frequencyRank == null)) {
|
||||||
return (
|
return (
|
||||||
@@ -81,12 +143,15 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
</span>
|
</span>
|
||||||
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
{hideKnown && hasKnownData ? 'Common Words Not Yet Mined' : 'Most Common Words Seen'}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{hasKnownData && (
|
{hasKnownData && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-pressed={hideKnown}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHideKnown(!hideKnown);
|
const next = !hideKnown;
|
||||||
|
setHideKnown(next);
|
||||||
|
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||||
@@ -98,12 +163,33 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
|
|||||||
Hide Known
|
Hide Known
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={hideKanaOnly}
|
||||||
|
onClick={() => {
|
||||||
|
const next = !hideKanaOnly;
|
||||||
|
setHideKanaOnly(next);
|
||||||
|
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
|
||||||
|
hideKanaOnly
|
||||||
|
? 'bg-ctp-surface2 text-ctp-text border-ctp-blue/50'
|
||||||
|
: 'bg-ctp-surface0 text-ctp-overlay2 border-ctp-surface1 hover:text-ctp-subtext0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hide Kana
|
||||||
|
</button>
|
||||||
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
<span className="text-xs text-ctp-overlay2">{ranked.length} words</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{collapsed ? null : ranked.length === 0 ? (
|
{collapsed ? null : ranked.length === 0 ? (
|
||||||
<div className="text-xs text-ctp-overlay2 mt-3">
|
<div className="text-xs text-ctp-overlay2 mt-3">
|
||||||
{hideKnown ? 'All ranked words are already in Anki!' : 'No words with frequency data.'}
|
{hideKnown && hasKnownData && !hideKanaOnly
|
||||||
|
? 'All ranked words are already in Anki!'
|
||||||
|
: (hideKnown && hasKnownData) || hideKanaOnly
|
||||||
|
? 'No ranked words match the active filters.'
|
||||||
|
: 'No words with frequency data.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function VocabularyTab({
|
|||||||
}: VocabularyTabProps) {
|
}: VocabularyTabProps) {
|
||||||
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
const { words, kanji, knownWords, loading, error } = useVocabulary();
|
||||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [hideNames, setHideNames] = useState(false);
|
const [hideNames, setHideNames] = useState(false);
|
||||||
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
const [showExclusionManager, setShowExclusionManager] = useState(false);
|
||||||
|
|
||||||
@@ -116,14 +115,7 @@ export function VocabularyTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-end gap-3">
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Search words..."
|
|
||||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
|
||||||
/>
|
|
||||||
{hasNames && (
|
{hasNames && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -178,12 +170,7 @@ export function VocabularyTab({
|
|||||||
onSelectWord={handleSelectWord}
|
onSelectWord={handleSelectWord}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WordList
|
<WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
|
||||||
words={filteredWords}
|
|
||||||
selectedKey={null}
|
|
||||||
onSelectWord={handleSelectWord}
|
|
||||||
search={search}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<KanjiBreakdown
|
<KanjiBreakdown
|
||||||
kanji={kanji}
|
kanji={kanji}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const WORD_DETAIL_PANEL_PATH = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'WordDetailPanel.tsx',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('WordDetailPanel uses the shared stats mining payload builder', () => {
|
||||||
|
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /buildStatsMineCardParams/);
|
||||||
|
assert.match(source, /getStatsMineCardUnavailableReason/);
|
||||||
|
assert.match(source, /buildStatsMineCardParams\(\s*occ,\s*data!\.detail\.headword,\s*mode\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WordDetailPanel shows partial media mining errors instead of silent success', () => {
|
||||||
|
const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /getStatsMineCardError/);
|
||||||
|
assert.match(source, /const responseError = getStatsMineCardError\(result\);/);
|
||||||
|
});
|
||||||
@@ -2,6 +2,11 @@ import { useRef, useState, useEffect } from 'react';
|
|||||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||||
|
import {
|
||||||
|
buildStatsMineCardParams,
|
||||||
|
getStatsMineCardError,
|
||||||
|
getStatsMineCardUnavailableReason,
|
||||||
|
} from '../../lib/mining';
|
||||||
import { fullReading } from '../../lib/reading-utils';
|
import { fullReading } from '../../lib/reading-utils';
|
||||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||||
import { PosBadge } from './pos-helpers';
|
import { PosBadge } from './pos-helpers';
|
||||||
@@ -135,25 +140,18 @@ export function WordDetailPanel({
|
|||||||
occ: VocabularyOccurrenceEntry,
|
occ: VocabularyOccurrenceEntry,
|
||||||
mode: 'word' | 'sentence' | 'audio',
|
mode: 'word' | 'sentence' | 'audio',
|
||||||
) => {
|
) => {
|
||||||
if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) {
|
const params = buildStatsMineCardParams(occ, data!.detail.headword, mode);
|
||||||
|
if (!params) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
|
const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`;
|
||||||
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.mineCard({
|
const result = await apiClient.mineCard(params);
|
||||||
sourcePath: occ.sourcePath!,
|
const responseError = getStatsMineCardError(result);
|
||||||
startMs: occ.segmentStartMs!,
|
if (responseError) {
|
||||||
endMs: occ.segmentEndMs!,
|
setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } }));
|
||||||
sentence: occ.text,
|
|
||||||
word: data!.detail.headword,
|
|
||||||
secondaryText: occ.secondaryText,
|
|
||||||
videoTitle: occ.videoTitle,
|
|
||||||
mode,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } }));
|
|
||||||
} else {
|
} else {
|
||||||
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
|
||||||
const label =
|
const label =
|
||||||
@@ -179,15 +177,15 @@ export function WordDetailPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40">
|
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close word detail panel"
|
aria-label="Close word detail panel"
|
||||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
<aside className="relative flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||||
@@ -301,7 +299,7 @@ export function WordDetailPanel({
|
|||||||
{data.similarWords.length > 0 && (
|
{data.similarWords.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">
|
||||||
Similar Words
|
Related Seen Words
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{data.similarWords.map((sw) => (
|
{data.similarWords.map((sw) => (
|
||||||
@@ -368,15 +366,7 @@ export function WordDetailPanel({
|
|||||||
· session {occ.sessionId}
|
· session {occ.sessionId}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const canMine =
|
const unavailableReason = getStatsMineCardUnavailableReason(occ);
|
||||||
!!occ.sourcePath &&
|
|
||||||
occ.segmentStartMs != null &&
|
|
||||||
occ.segmentEndMs != null;
|
|
||||||
const unavailableReason = canMine
|
|
||||||
? null
|
|
||||||
: occ.sourcePath
|
|
||||||
? 'This line is missing segment timing.'
|
|
||||||
: 'This source has no local file path.';
|
|
||||||
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`;
|
||||||
const wordStatus = mineStatus[`${baseKey}-word`];
|
const wordStatus = mineStatus[`${baseKey}-word`];
|
||||||
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
const sentenceStatus = mineStatus[`${baseKey}-sentence`];
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function posColor(pos: string): string {
|
|||||||
|
|
||||||
export function PosBadge({ pos }: { pos: string }) {
|
export function PosBadge({ pos }: { pos: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}
|
||||||
|
>
|
||||||
{pos.replace(/_/g, ' ')}
|
{pos.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
buildCoverImageRequestKey,
|
||||||
|
collectSessionCoverRequests,
|
||||||
|
getCoverImageKey,
|
||||||
|
mergeCoverImageData,
|
||||||
|
type CoverImageMap,
|
||||||
|
} from '../lib/cover-images';
|
||||||
|
import { getCoverRetryDelayMs } from '../lib/cover-retry';
|
||||||
|
import type { SessionSummary } from '../types/stats';
|
||||||
|
import { getStatsClient } from './useStatsApi';
|
||||||
|
|
||||||
|
interface UseCoverImagesOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCoverImages(
|
||||||
|
sessions: SessionSummary[],
|
||||||
|
options: UseCoverImagesOptions = {},
|
||||||
|
): CoverImageMap {
|
||||||
|
const enabled = options.enabled ?? true;
|
||||||
|
const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]);
|
||||||
|
const requestKey = useMemo(
|
||||||
|
() => buildCoverImageRequestKey(requests.animeIds, requests.videoIds, enabled ? 1 : 0),
|
||||||
|
[requests, enabled],
|
||||||
|
);
|
||||||
|
const [images, setImages] = useState<CoverImageMap>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let cachedImages: CoverImageMap = {};
|
||||||
|
const client = getStatsClient();
|
||||||
|
|
||||||
|
async function load(animeIds: number[], videoIds: number[], attempt: number): Promise<void> {
|
||||||
|
if (animeIds.length === 0 && videoIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await client.getCoverImages({ animeIds, videoIds });
|
||||||
|
if (cancelled) return;
|
||||||
|
cachedImages = mergeCoverImageData(cachedImages, data);
|
||||||
|
setImages(cachedImages);
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingAnimeIds = animeIds.filter((id) => !cachedImages[getCoverImageKey('anime', id)]);
|
||||||
|
const missingVideoIds = videoIds.filter((id) => !cachedImages[getCoverImageKey('media', id)]);
|
||||||
|
if (missingAnimeIds.length === 0 && missingVideoIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void load(missingAnimeIds, missingVideoIds, attempt + 1);
|
||||||
|
}, getCoverRetryDelayMs(attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requests.animeIds.length === 0 && requests.videoIds.length === 0) {
|
||||||
|
setImages({});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void load(requests.animeIds, requests.videoIds, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [requestKey]);
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
@@ -1,6 +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 {
|
import {
|
||||||
|
isExcludedWord,
|
||||||
getExcludedWordsSnapshot,
|
getExcludedWordsSnapshot,
|
||||||
initializeExcludedWordsStore,
|
initializeExcludedWordsStore,
|
||||||
resetExcludedWordsStoreForTests,
|
resetExcludedWordsStoreForTests,
|
||||||
@@ -100,6 +101,46 @@ test('setExcludedWords updates the database-backed exclusion list', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setExcludedWords persists one row per excluded token', async () => {
|
||||||
|
resetExcludedWordsStoreForTests();
|
||||||
|
const { values: storage, restore } = installLocalStorage();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let seenBody = '';
|
||||||
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
seenBody = String(init?.body ?? '');
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = [
|
||||||
|
{ headword: 'ない', word: 'ない', reading: 'ない' },
|
||||||
|
{ headword: 'ない', word: '無い', reading: 'ない' },
|
||||||
|
];
|
||||||
|
const expected = [{ headword: 'ない', word: 'ない', reading: 'ない' }];
|
||||||
|
|
||||||
|
await setExcludedWords(rows);
|
||||||
|
|
||||||
|
assert.deepEqual(getExcludedWordsSnapshot(), expected);
|
||||||
|
assert.equal(seenBody, JSON.stringify({ words: expected }));
|
||||||
|
assert.equal(storage.get(STORAGE_KEY), JSON.stringify(expected));
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
restore();
|
||||||
|
resetExcludedWordsStoreForTests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion matching covers vocabulary rows with the same visible token', () => {
|
||||||
|
const excluded = [{ headword: 'ない', word: 'ない', reading: 'ない' }];
|
||||||
|
|
||||||
|
assert.equal(isExcludedWord(excluded, { headword: 'ない', word: '無い', reading: 'ない' }), true);
|
||||||
|
assert.equal(isExcludedWord(excluded, { headword: '無い', word: 'ない', reading: 'ない' }), true);
|
||||||
|
assert.equal(
|
||||||
|
isExcludedWord(excluded, { headword: 'なる', word: 'なる', reading: 'なる' }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('setExcludedWords rolls back local state when persistence fails', async () => {
|
test('setExcludedWords rolls back local state when persistence fails', async () => {
|
||||||
resetExcludedWordsStoreForTests();
|
resetExcludedWordsStoreForTests();
|
||||||
const previousRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
|
const previousRows = [{ headword: '猫', word: '猫', reading: 'ねこ' }];
|
||||||
|
|||||||
@@ -6,8 +6,37 @@ export type ExcludedWord = StatsExcludedWord;
|
|||||||
|
|
||||||
const STORAGE_KEY = 'subminer-excluded-words';
|
const STORAGE_KEY = 'subminer-excluded-words';
|
||||||
|
|
||||||
function toKey(w: ExcludedWord): string {
|
type ExclusionCandidate = { headword: string; word: string; reading: string };
|
||||||
return `${w.headword}\0${w.word}\0${w.reading}`;
|
|
||||||
|
function normalizedTokenText(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExcludedWordTokenKey(w: ExclusionCandidate): string {
|
||||||
|
return (
|
||||||
|
normalizedTokenText(w.headword) || normalizedTokenText(w.word) || normalizedTokenText(w.reading)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExcludedWordAliasKeys(w: ExclusionCandidate): string[] {
|
||||||
|
const aliases = [normalizedTokenText(w.headword), normalizedTokenText(w.word)].filter(Boolean);
|
||||||
|
const unique = new Set(aliases);
|
||||||
|
if (unique.size === 0) unique.add(getExcludedWordTokenKey(w));
|
||||||
|
return [...unique];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeExcludedWords(words: ExcludedWord[]): ExcludedWord[] {
|
||||||
|
const byToken = new Map<string, ExcludedWord>();
|
||||||
|
for (const word of words) {
|
||||||
|
const key = getExcludedWordTokenKey(word);
|
||||||
|
if (!byToken.has(key)) byToken.set(key, word);
|
||||||
|
}
|
||||||
|
return [...byToken.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExcludedWord(excluded: ExcludedWord[], w: ExclusionCandidate): boolean {
|
||||||
|
const excludedKeys = new Set(excluded.flatMap(getExcludedWordAliasKeys));
|
||||||
|
return getExcludedWordAliasKeys(w).some((key) => excludedKeys.has(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: ExcludedWord[] | null = null;
|
let cached: ExcludedWord[] | null = null;
|
||||||
@@ -22,13 +51,15 @@ function readLocalStorage(): ExcludedWord[] {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (!Array.isArray(parsed)) return [];
|
||||||
return parsed.filter(
|
return dedupeExcludedWords(
|
||||||
|
parsed.filter(
|
||||||
(row): row is ExcludedWord =>
|
(row): row is ExcludedWord =>
|
||||||
row !== null &&
|
row !== null &&
|
||||||
typeof row === 'object' &&
|
typeof row === 'object' &&
|
||||||
typeof (row as ExcludedWord).headword === 'string' &&
|
typeof (row as ExcludedWord).headword === 'string' &&
|
||||||
typeof (row as ExcludedWord).word === 'string' &&
|
typeof (row as ExcludedWord).word === 'string' &&
|
||||||
typeof (row as ExcludedWord).reading === 'string',
|
typeof (row as ExcludedWord).reading === 'string',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -48,14 +79,15 @@ function load(): ExcludedWord[] {
|
|||||||
|
|
||||||
function getKeySet(): Set<string> {
|
function getKeySet(): Set<string> {
|
||||||
if (cachedKeys) return cachedKeys;
|
if (cachedKeys) return cachedKeys;
|
||||||
cachedKeys = new Set(load().map(toKey));
|
cachedKeys = new Set(load().flatMap(getExcludedWordAliasKeys));
|
||||||
return cachedKeys;
|
return cachedKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWords(words: ExcludedWord[]): void {
|
function applyWords(words: ExcludedWord[]): void {
|
||||||
cached = words;
|
const normalized = dedupeExcludedWords(words);
|
||||||
cachedKeys = new Set(words.map(toKey));
|
cached = normalized;
|
||||||
writeLocalStorage(words);
|
cachedKeys = new Set(normalized.flatMap(getExcludedWordAliasKeys));
|
||||||
|
writeLocalStorage(normalized);
|
||||||
for (const fn of listeners) fn();
|
for (const fn of listeners) fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +99,11 @@ export async function setExcludedWords(words: ExcludedWord[]): Promise<void> {
|
|||||||
const previousWords = [...load()];
|
const previousWords = [...load()];
|
||||||
const previousRevision = revision;
|
const previousRevision = revision;
|
||||||
const writeRevision = previousRevision + 1;
|
const writeRevision = previousRevision + 1;
|
||||||
|
const normalized = dedupeExcludedWords(words);
|
||||||
revision = writeRevision;
|
revision = writeRevision;
|
||||||
applyWords(words);
|
applyWords(normalized);
|
||||||
try {
|
try {
|
||||||
await apiClient.setExcludedWords(words);
|
await apiClient.setExcludedWords(normalized);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (revision === writeRevision) {
|
if (revision === writeRevision) {
|
||||||
revision = previousRevision;
|
revision = previousRevision;
|
||||||
@@ -137,22 +170,27 @@ export function useExcludedWords() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isExcluded = useCallback(
|
const isExcluded = useCallback(
|
||||||
(w: { headword: string; word: string; reading: string }) => getKeySet().has(toKey(w)),
|
(w: ExclusionCandidate) => getExcludedWordAliasKeys(w).some((key) => getKeySet().has(key)),
|
||||||
[excluded],
|
[excluded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleExclusion = useCallback((w: ExcludedWord) => {
|
const toggleExclusion = useCallback((w: ExcludedWord) => {
|
||||||
const key = toKey(w);
|
|
||||||
const current = load();
|
const current = load();
|
||||||
if (getKeySet().has(key)) {
|
const candidateKeys = new Set(getExcludedWordAliasKeys(w));
|
||||||
void setExcludedWords(current.filter((e) => toKey(e) !== key));
|
const existing = current.find((e) =>
|
||||||
|
getExcludedWordAliasKeys(e).some((key) => candidateKeys.has(key)),
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
const key = getExcludedWordTokenKey(existing);
|
||||||
|
void setExcludedWords(current.filter((e) => getExcludedWordTokenKey(e) !== key));
|
||||||
} else {
|
} else {
|
||||||
void setExcludedWords([...current, w]);
|
void setExcludedWords([...current, w]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeExclusion = useCallback((w: ExcludedWord) => {
|
const removeExclusion = useCallback((w: ExcludedWord) => {
|
||||||
void setExcludedWords(load().filter((e) => toKey(e) !== toKey(w)));
|
const key = getExcludedWordTokenKey(w);
|
||||||
|
void setExcludedWords(load().filter((e) => getExcludedWordTokenKey(e) !== key));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearAll = useCallback(() => {
|
const clearAll = useCallback(() => {
|
||||||
|
|||||||
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
|
|||||||
assert.equal(baseUrl, 'http://127.0.0.1:6969');
|
assert.equal(baseUrl, 'http://127.0.0.1:6969');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
||||||
|
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
|
||||||
|
animeId: number,
|
||||||
|
retryToken?: number,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
getAnimeCoverUrl(42, 3),
|
||||||
|
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCoverImages batches anime and media cover requests', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let seenUrl = '';
|
||||||
|
let seenMethod = '';
|
||||||
|
let seenBody = '';
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
seenUrl = String(input);
|
||||||
|
seenMethod = init?.method ?? 'GET';
|
||||||
|
seenBody = String(init?.body ?? '');
|
||||||
|
return new Response(JSON.stringify({ anime: {}, media: {} }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
|
||||||
|
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
|
||||||
|
assert.equal(seenMethod, 'POST');
|
||||||
|
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let seenUrl = '';
|
let seenUrl = '';
|
||||||
@@ -51,6 +88,34 @@ test('deleteSession sends a DELETE request to the session endpoint', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searchSentences encodes realtime sentence search requests', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let seenUrl = '';
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
seenUrl = String(input);
|
||||||
|
return new Response(JSON.stringify([]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.searchSentences('猫 食べる', 25);
|
||||||
|
assert.equal(
|
||||||
|
seenUrl,
|
||||||
|
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await apiClient.searchSentences('猫 食べる', 25, false);
|
||||||
|
assert.equal(
|
||||||
|
seenUrl,
|
||||||
|
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('deleteSession throws when the stats API delete request fails', async () => {
|
test('deleteSession throws when the stats API delete request fails', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = (async () =>
|
globalThis.fetch = (async () =>
|
||||||
|
|||||||
+40
-11
@@ -6,6 +6,7 @@ import type {
|
|||||||
SessionTimelinePoint,
|
SessionTimelinePoint,
|
||||||
SessionEvent,
|
SessionEvent,
|
||||||
VocabularyEntry,
|
VocabularyEntry,
|
||||||
|
SentenceSearchResult,
|
||||||
KanjiEntry,
|
KanjiEntry,
|
||||||
VocabularyOccurrenceEntry,
|
VocabularyOccurrenceEntry,
|
||||||
MediaLibraryItem,
|
MediaLibraryItem,
|
||||||
@@ -23,7 +24,10 @@ import type {
|
|||||||
EpisodeDetailData,
|
EpisodeDetailData,
|
||||||
StatsAnkiNoteInfo,
|
StatsAnkiNoteInfo,
|
||||||
StatsExcludedWord,
|
StatsExcludedWord,
|
||||||
|
StatsCoverImagesData,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
|
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||||
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
|
|
||||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||||
|
|
||||||
@@ -65,6 +69,16 @@ async function fetchJson<T>(path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniquePositiveIds(ids: number[]): number[] {
|
||||||
|
const uniqueIds = new Set<number>();
|
||||||
|
for (const id of ids) {
|
||||||
|
if (Number.isFinite(id) && id > 0) {
|
||||||
|
uniqueIds.add(Math.floor(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(uniqueIds).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||||
getDailyRollups: (limit = 60) =>
|
getDailyRollups: (limit = 60) =>
|
||||||
@@ -103,6 +117,14 @@ export const apiClient = {
|
|||||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||||
),
|
),
|
||||||
|
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
|
||||||
|
fetchJson<SentenceSearchResult[]>(
|
||||||
|
`/api/stats/sentences/search?${new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
limit: String(limit),
|
||||||
|
headword: String(searchByHeadword),
|
||||||
|
}).toString()}`,
|
||||||
|
),
|
||||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
||||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||||
@@ -116,7 +138,22 @@ export const apiClient = {
|
|||||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||||
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
|
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
||||||
|
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
||||||
|
getCoverImages: async (params: {
|
||||||
|
animeIds: number[];
|
||||||
|
videoIds: number[];
|
||||||
|
}): Promise<StatsCoverImagesData> => {
|
||||||
|
const res = await fetchResponse('/api/stats/covers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
animeIds: uniquePositiveIds(params.animeIds),
|
||||||
|
videoIds: uniquePositiveIds(params.videoIds),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return res.json() as Promise<StatsCoverImagesData>;
|
||||||
|
},
|
||||||
getStreakCalendar: (days = 90) =>
|
getStreakCalendar: (days = 90) =>
|
||||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||||
getEpisodesPerDay: (limit = 90) =>
|
getEpisodesPerDay: (limit = 90) =>
|
||||||
@@ -175,6 +212,7 @@ export const apiClient = {
|
|||||||
episodes: number | null;
|
episodes: number | null;
|
||||||
season: string | null;
|
season: string | null;
|
||||||
seasonYear: number | null;
|
seasonYear: number | null;
|
||||||
|
description: string | null;
|
||||||
coverImage: { large: string | null; medium: string | null } | null;
|
coverImage: { large: string | null; medium: string | null } | null;
|
||||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||||
}>
|
}>
|
||||||
@@ -197,16 +235,7 @@ export const apiClient = {
|
|||||||
body: JSON.stringify(info),
|
body: JSON.stringify(info),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mineCard: async (params: {
|
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
|
||||||
sourcePath: string;
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
sentence: string;
|
|
||||||
word: string;
|
|
||||||
secondaryText?: string | null;
|
|
||||||
videoTitle: string;
|
|
||||||
mode: 'word' | 'sentence' | 'audio';
|
|
||||||
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
|
|
||||||
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
|
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
|||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
|
||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
|
||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
|
||||||
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/search\/SearchTab'\)/);
|
||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
|
||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
|
||||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
|
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
|
||||||
@@ -23,6 +24,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
|||||||
source,
|
source,
|
||||||
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
|
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
|
||||||
);
|
);
|
||||||
|
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
source,
|
source,
|
||||||
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
|
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildCoverImageRequestKey,
|
||||||
|
collectSessionCoverRequests,
|
||||||
|
getCoverImageKey,
|
||||||
|
} from './cover-images';
|
||||||
|
import type { SessionSummary } from '../types/stats';
|
||||||
|
|
||||||
|
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||||
|
const { sessionId, ...rest } = overrides;
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
canonicalTitle: null,
|
||||||
|
videoId: null,
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: null,
|
||||||
|
startedAtMs: 0,
|
||||||
|
endedAtMs: null,
|
||||||
|
totalWatchedMs: 0,
|
||||||
|
activeWatchedMs: 0,
|
||||||
|
linesSeen: 0,
|
||||||
|
tokensSeen: 0,
|
||||||
|
cardsMined: 0,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 0,
|
||||||
|
knownWordsSeen: 0,
|
||||||
|
knownWordRate: 0,
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('collectSessionCoverRequests dedupes anime ids and only requests media for ungrouped sessions', () => {
|
||||||
|
const requests = collectSessionCoverRequests([
|
||||||
|
makeSession({ sessionId: 1, animeId: 10, videoId: 100 }),
|
||||||
|
makeSession({ sessionId: 2, animeId: 10, videoId: 101 }),
|
||||||
|
makeSession({ sessionId: 3, animeId: null, videoId: 200 }),
|
||||||
|
makeSession({ sessionId: 4, animeId: null, videoId: 200 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(requests, { animeIds: [10], videoIds: [200] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCoverImageKey separates anime and media ids', () => {
|
||||||
|
assert.equal(getCoverImageKey('anime', 1), 'anime:1');
|
||||||
|
assert.equal(getCoverImageKey('media', 1), 'media:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCoverImageRequestKey changes when callers force a cover refresh', () => {
|
||||||
|
assert.notEqual(buildCoverImageRequestKey([10], [], 0), buildCoverImageRequestKey([10], [], 1));
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { SessionSummary, StatsCoverImagesData } from '../types/stats';
|
||||||
|
|
||||||
|
export type CoverImageKind = 'anime' | 'media';
|
||||||
|
export type CoverImageMap = Record<string, string>;
|
||||||
|
|
||||||
|
export interface CoverImageRequest {
|
||||||
|
animeIds: number[];
|
||||||
|
videoIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveIds(ids: Iterable<number | null | undefined>): number[] {
|
||||||
|
const uniqueIds = new Set<number>();
|
||||||
|
for (const id of ids) {
|
||||||
|
if (typeof id === 'number' && Number.isFinite(id) && id > 0) {
|
||||||
|
uniqueIds.add(Math.floor(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(uniqueIds).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverImageKey(kind: CoverImageKind, id: number): string {
|
||||||
|
return `${kind}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCoverImageRequestKey(
|
||||||
|
animeIds: number[],
|
||||||
|
videoIds: number[],
|
||||||
|
refreshToken = 0,
|
||||||
|
): string {
|
||||||
|
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}|r:${refreshToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSessionCoverRequests(
|
||||||
|
sessions: Pick<SessionSummary, 'animeId' | 'videoId'>[],
|
||||||
|
): CoverImageRequest {
|
||||||
|
const animeIds: number[] = [];
|
||||||
|
const videoIds: number[] = [];
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.animeId != null) {
|
||||||
|
animeIds.push(session.animeId);
|
||||||
|
} else if (session.videoId != null) {
|
||||||
|
videoIds.push(session.videoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
animeIds: normalizePositiveIds(animeIds),
|
||||||
|
videoIds: normalizePositiveIds(videoIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCoverImageData(
|
||||||
|
previous: CoverImageMap,
|
||||||
|
data: StatsCoverImagesData,
|
||||||
|
): CoverImageMap {
|
||||||
|
const next = { ...previous };
|
||||||
|
|
||||||
|
for (const [id, image] of Object.entries(data.anime)) {
|
||||||
|
if (image?.dataUrl) {
|
||||||
|
next[getCoverImageKey('anime', Number(id))] = image.dataUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, image] of Object.entries(data.media)) {
|
||||||
|
if (image?.dataUrl) {
|
||||||
|
next[getCoverImageKey('media', Number(id))] = image.dataUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverImageSrc(
|
||||||
|
images: CoverImageMap,
|
||||||
|
kind: CoverImageKind,
|
||||||
|
id: number | null,
|
||||||
|
): string | null {
|
||||||
|
return id == null ? null : (images[getCoverImageKey(kind, id)] ?? null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const COVER_RETRY_PARAM = 'coverRetry';
|
||||||
|
|
||||||
|
export function appendCoverRetryToken(src: string, retryToken = 0): string {
|
||||||
|
if (!Number.isFinite(retryToken) || retryToken <= 0) return src;
|
||||||
|
|
||||||
|
const normalizedToken = String(Math.trunc(retryToken));
|
||||||
|
try {
|
||||||
|
// Dummy base lets URL parse relative API paths; it is never returned as a real host.
|
||||||
|
const url = new URL(src, 'http://subminer.local');
|
||||||
|
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
|
||||||
|
if (src.startsWith('/')) {
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
const separator = src.includes('?') ? '&' : '?';
|
||||||
|
return `${src}${separator}${COVER_RETRY_PARAM}=${encodeURIComponent(normalizedToken)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverRetryDelayMs(retryToken: number): number {
|
||||||
|
return Math.min(30_000, 2_000 * 2 ** Math.min(Math.max(retryToken, 0), 4));
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_CARD_SIZE,
|
||||||
|
LIBRARY_CARD_SIZE_STORAGE_KEY,
|
||||||
|
getLibraryCardSizeStorage,
|
||||||
|
readLibraryCardSizePreference,
|
||||||
|
writeLibraryCardSizePreference,
|
||||||
|
} from './library-card-size';
|
||||||
|
|
||||||
|
function createStorage(initial: Record<string, string | null> = {}): Storage {
|
||||||
|
const values = new Map(
|
||||||
|
Object.entries(initial).filter((entry): entry is [string, string] => {
|
||||||
|
return entry[1] !== null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get length() {
|
||||||
|
return values.size;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
values.clear();
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return values.get(key) ?? null;
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(values.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
values.delete(key);
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('readLibraryCardSizePreference returns saved valid sizes', () => {
|
||||||
|
const storage = createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'lg' });
|
||||||
|
|
||||||
|
assert.equal(readLibraryCardSizePreference(storage), 'lg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLibraryCardSizePreference falls back for missing or invalid saved sizes', () => {
|
||||||
|
assert.equal(readLibraryCardSizePreference(createStorage()), DEFAULT_LIBRARY_CARD_SIZE);
|
||||||
|
assert.equal(
|
||||||
|
readLibraryCardSizePreference(createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'xl' })),
|
||||||
|
DEFAULT_LIBRARY_CARD_SIZE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library card size preference helpers ignore storage failures', () => {
|
||||||
|
const storage = {
|
||||||
|
getItem() {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
setItem() {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
} as unknown as Storage;
|
||||||
|
|
||||||
|
assert.equal(readLibraryCardSizePreference(storage), DEFAULT_LIBRARY_CARD_SIZE);
|
||||||
|
assert.doesNotThrow(() => writeLibraryCardSizePreference(storage, 'sm'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLibraryCardSizeStorage returns null when localStorage access is blocked', () => {
|
||||||
|
const source = {
|
||||||
|
get localStorage(): Storage {
|
||||||
|
throw new Error('blocked');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(getLibraryCardSizeStorage(source), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export type LibraryCardSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
export const DEFAULT_LIBRARY_CARD_SIZE: LibraryCardSize = 'md';
|
||||||
|
export const LIBRARY_CARD_SIZE_STORAGE_KEY = 'subminer.stats.library.cardSize';
|
||||||
|
|
||||||
|
export function getLibraryCardSizeStorage(
|
||||||
|
source: { localStorage: Storage } | null | undefined,
|
||||||
|
): Storage | null {
|
||||||
|
try {
|
||||||
|
return source?.localStorage ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLibraryCardSizePreference(
|
||||||
|
storage: Storage | null | undefined,
|
||||||
|
): LibraryCardSize {
|
||||||
|
try {
|
||||||
|
const value = storage?.getItem(LIBRARY_CARD_SIZE_STORAGE_KEY);
|
||||||
|
return value === 'sm' || value === 'md' || value === 'lg' ? value : DEFAULT_LIBRARY_CARD_SIZE;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LIBRARY_CARD_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLibraryCardSizePreference(
|
||||||
|
storage: Storage | null | undefined,
|
||||||
|
size: LibraryCardSize,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(LIBRARY_CARD_SIZE_STORAGE_KEY, size);
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
|
|||||||
import {
|
import {
|
||||||
groupMediaLibraryItems,
|
groupMediaLibraryItems,
|
||||||
resolveMediaArtworkUrl,
|
resolveMediaArtworkUrl,
|
||||||
|
resolveMediaCoverApiUrl,
|
||||||
summarizeMediaLibraryGroups,
|
summarizeMediaLibraryGroups,
|
||||||
} from './media-library-grouping';
|
} from './media-library-grouping';
|
||||||
import { CoverImage } from '../components/library/CoverImage';
|
import { CoverImage } from '../components/library/CoverImage';
|
||||||
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
|
|||||||
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
|
||||||
|
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BASE_URL } from './api-client';
|
import { BASE_URL } from './api-client';
|
||||||
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
|
||||||
export interface MediaLibraryGroup {
|
export interface MediaLibraryGroup {
|
||||||
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
|
|||||||
return normalized.length > 0 ? normalized : null;
|
return normalized.length > 0 ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMediaCoverApiUrl(videoId: number): string {
|
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
|
||||||
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildStatsMineCardParams,
|
||||||
|
getStatsMineCardError,
|
||||||
|
getStatsMineCardUnavailableReason,
|
||||||
|
} from './mining';
|
||||||
|
import type { SentenceSearchResult } from '../types/stats';
|
||||||
|
|
||||||
|
function makeResult(overrides: Partial<SentenceSearchResult> = {}): SentenceSearchResult {
|
||||||
|
return {
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: 'Little Witch Academia',
|
||||||
|
videoId: 4,
|
||||||
|
videoTitle: 'Episode 4',
|
||||||
|
sourcePath: '/tmp/lwa.mkv',
|
||||||
|
secondaryText: 'Magic is gone',
|
||||||
|
sessionId: 7,
|
||||||
|
lineIndex: 12,
|
||||||
|
segmentStartMs: 5_000,
|
||||||
|
segmentEndMs: 6_000,
|
||||||
|
text: '魔法がなくなった',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => {
|
||||||
|
assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), {
|
||||||
|
sourcePath: '/tmp/lwa.mkv',
|
||||||
|
startMs: 5_000,
|
||||||
|
endMs: 6_000,
|
||||||
|
sentence: '魔法がなくなった',
|
||||||
|
word: '魔法',
|
||||||
|
secondaryText: 'Magic is gone',
|
||||||
|
videoTitle: 'Episode 4',
|
||||||
|
mode: 'sentence',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildStatsMineCardParams returns null when media context is incomplete', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildStatsMineCardParams(
|
||||||
|
makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }),
|
||||||
|
'魔法',
|
||||||
|
'sentence',
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })),
|
||||||
|
'This line has invalid segment timing.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getStatsMineCardError surfaces partial media failures', () => {
|
||||||
|
assert.equal(
|
||||||
|
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
|
||||||
|
'audio: ffmpeg failed',
|
||||||
|
);
|
||||||
|
assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found');
|
||||||
|
assert.equal(getStatsMineCardError({ noteId: 1 }), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { SentenceSearchResult } from '../types/stats';
|
||||||
|
|
||||||
|
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||||
|
|
||||||
|
export interface StatsMineCardParams {
|
||||||
|
sourcePath: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
sentence: string;
|
||||||
|
word: string;
|
||||||
|
secondaryText?: string | null;
|
||||||
|
videoTitle: string;
|
||||||
|
mode: StatsMineMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsMineCardResponse {
|
||||||
|
noteId?: number;
|
||||||
|
error?: string;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsMineCardUnavailableReason(
|
||||||
|
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
||||||
|
): string | null {
|
||||||
|
if (!result.sourcePath) {
|
||||||
|
return 'This source has no local file path.';
|
||||||
|
}
|
||||||
|
if (result.segmentStartMs == null || result.segmentEndMs == null) {
|
||||||
|
return 'This line is missing segment timing.';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isFinite(result.segmentStartMs) ||
|
||||||
|
!Number.isFinite(result.segmentEndMs) ||
|
||||||
|
result.segmentEndMs <= result.segmentStartMs
|
||||||
|
) {
|
||||||
|
return 'This line has invalid segment timing.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatsMineCardParams(
|
||||||
|
result: Pick<
|
||||||
|
SentenceSearchResult,
|
||||||
|
'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle'
|
||||||
|
>,
|
||||||
|
word: string,
|
||||||
|
mode: StatsMineMode,
|
||||||
|
): StatsMineCardParams | null {
|
||||||
|
if (getStatsMineCardUnavailableReason(result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourcePath: result.sourcePath!,
|
||||||
|
startMs: result.segmentStartMs!,
|
||||||
|
endMs: result.segmentEndMs!,
|
||||||
|
sentence: result.text,
|
||||||
|
word,
|
||||||
|
secondaryText: result.secondaryText,
|
||||||
|
videoTitle: result.videoTitle,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatsMineCardError(response: StatsMineCardResponse): string | null {
|
||||||
|
if (response.error) return response.error;
|
||||||
|
return response.errors?.[0] ?? null;
|
||||||
|
}
|
||||||
@@ -1,51 +1,52 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
import { fullReading } from './reading-utils';
|
import { fullReading } from './reading-utils';
|
||||||
|
|
||||||
describe('fullReading', () => {
|
describe('fullReading', () => {
|
||||||
it('prefixes leading hiragana from headword', () => {
|
it('prefixes leading hiragana from headword', () => {
|
||||||
// お前 with reading まえ → おまえ
|
// お前 with reading まえ → おまえ
|
||||||
expect(fullReading('お前', 'まえ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles katakana stored readings', () => {
|
it('handles katakana stored readings', () => {
|
||||||
// お前 with katakana reading マエ → おまえ
|
// お前 with katakana reading マエ → おまえ
|
||||||
expect(fullReading('お前', 'マエ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns stored reading when it already includes leading kana', () => {
|
it('returns stored reading when it already includes leading kana', () => {
|
||||||
// Reading already correct
|
// Reading already correct
|
||||||
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles trailing hiragana', () => {
|
it('handles trailing hiragana', () => {
|
||||||
// 隠す with reading かくす — す is trailing hiragana
|
// 隠す with reading かくす — す is trailing hiragana
|
||||||
expect(fullReading('隠す', 'かくす')).toBe('かくす');
|
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles pure kanji headwords', () => {
|
it('handles pure kanji headwords', () => {
|
||||||
expect(fullReading('様', 'さま')).toBe('さま');
|
assert.equal(fullReading('様', 'さま'), 'さま');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for empty reading', () => {
|
it('returns empty for empty reading', () => {
|
||||||
expect(fullReading('前', '')).toBe('');
|
assert.equal(fullReading('前', ''), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for empty headword', () => {
|
it('returns empty for empty headword', () => {
|
||||||
expect(fullReading('', 'まえ')).toBe('まえ');
|
assert.equal(fullReading('', 'まえ'), 'まえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles all-kana headword', () => {
|
it('handles all-kana headword', () => {
|
||||||
// Headword is already all hiragana
|
// Headword is already all hiragana
|
||||||
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
|
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles mixed leading and trailing kana', () => {
|
it('handles mixed leading and trailing kana', () => {
|
||||||
// お気に入り: お=leading, に入り=trailing around 気
|
// お気に入り: お=leading, に入り=trailing around 気
|
||||||
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
|
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles katakana in headword', () => {
|
it('handles katakana in headword', () => {
|
||||||
// カズマ様 — leading katakana + kanji
|
// カズマ様 — leading katakana + kanji
|
||||||
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
|
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
|||||||
const chars = [...headword];
|
const chars = [...headword];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
|
while (i < chars.length) {
|
||||||
leadingKana.push(katakanaToHiragana(chars[i]));
|
const ch = chars[i]!;
|
||||||
|
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||||
|
leadingKana.push(katakanaToHiragana(ch));
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let j = chars.length - 1;
|
let j = chars.length - 1;
|
||||||
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
|
while (j > i) {
|
||||||
trailingKana.unshift(katakanaToHiragana(chars[j]));
|
const ch = chars[j]!;
|
||||||
|
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||||
|
trailingKana.unshift(katakanaToHiragana(ch));
|
||||||
j--;
|
j--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import {
|
||||||
|
findExactSentenceMatches,
|
||||||
|
getSentenceSearchMineAvailability,
|
||||||
|
renderSentenceWithMatches,
|
||||||
|
} from './sentence-search';
|
||||||
|
import type { SentenceSearchResult } from '../types/stats';
|
||||||
|
|
||||||
|
function makeResult(over: Partial<SentenceSearchResult>): SentenceSearchResult {
|
||||||
|
return {
|
||||||
|
animeId: null,
|
||||||
|
animeTitle: null,
|
||||||
|
videoId: 1,
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
sourcePath: '/tmp/video.mkv',
|
||||||
|
secondaryText: null,
|
||||||
|
sessionId: 10,
|
||||||
|
lineIndex: 3,
|
||||||
|
segmentStartMs: 1000,
|
||||||
|
segmentEndMs: 2500,
|
||||||
|
text: '猫が猫を見た',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('findExactSentenceMatches returns every exact searched-word range', () => {
|
||||||
|
assert.deepEqual(findExactSentenceMatches('猫が猫を見た', '猫'), [
|
||||||
|
{ start: 0, end: 1 },
|
||||||
|
{ start: 2, end: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSentenceSearchMineAvailability gates word and audio mining on exact sentence match', () => {
|
||||||
|
const result = makeResult({});
|
||||||
|
|
||||||
|
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||||
|
canMineSentence: true,
|
||||||
|
canMineWordAudio: true,
|
||||||
|
exactMatch: true,
|
||||||
|
unavailableReason: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(getSentenceSearchMineAvailability(result, '犬'), {
|
||||||
|
canMineSentence: true,
|
||||||
|
canMineWordAudio: false,
|
||||||
|
exactMatch: false,
|
||||||
|
unavailableReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSentenceSearchMineAvailability disables every mining mode without source timing', () => {
|
||||||
|
const result = makeResult({ sourcePath: null, segmentEndMs: null });
|
||||||
|
|
||||||
|
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||||
|
canMineSentence: false,
|
||||||
|
canMineWordAudio: false,
|
||||||
|
exactMatch: true,
|
||||||
|
unavailableReason: 'This source has no local file path.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getSentenceSearchMineAvailability disables every mining mode with invalid source timing', () => {
|
||||||
|
const result = makeResult({ segmentStartMs: 2500, segmentEndMs: 2400 });
|
||||||
|
|
||||||
|
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||||
|
canMineSentence: false,
|
||||||
|
canMineWordAudio: false,
|
||||||
|
exactMatch: true,
|
||||||
|
unavailableReason: 'This line has invalid segment timing.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderSentenceWithMatches highlights exact searched-word matches', () => {
|
||||||
|
const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}</>);
|
||||||
|
|
||||||
|
assert.match(markup, /<mark/);
|
||||||
|
assert.match(markup, />猫<\/mark>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SentenceSearchResult } from '../types/stats';
|
||||||
|
import { getStatsMineCardUnavailableReason } from './mining';
|
||||||
|
|
||||||
|
export interface SentenceMatchRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchMineAvailability {
|
||||||
|
canMineSentence: boolean;
|
||||||
|
canMineWordAudio: boolean;
|
||||||
|
exactMatch: boolean;
|
||||||
|
unavailableReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSearchWord(query: string): string {
|
||||||
|
return query.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
|
||||||
|
const needle = normalizedSearchWord(query);
|
||||||
|
if (!needle) return [];
|
||||||
|
|
||||||
|
const ranges: SentenceMatchRange[] = [];
|
||||||
|
const haystack = text.toLocaleLowerCase();
|
||||||
|
const normalizedNeedle = needle.toLocaleLowerCase();
|
||||||
|
let searchFrom = 0;
|
||||||
|
|
||||||
|
while (searchFrom < haystack.length) {
|
||||||
|
const index = haystack.indexOf(normalizedNeedle, searchFrom);
|
||||||
|
if (index < 0) break;
|
||||||
|
ranges.push({ start: index, end: index + needle.length });
|
||||||
|
searchFrom = index + needle.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSentenceSearchMineAvailability(
|
||||||
|
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text'>,
|
||||||
|
query: string,
|
||||||
|
): SentenceSearchMineAvailability {
|
||||||
|
const exactMatch = findExactSentenceMatches(result.text, query).length > 0;
|
||||||
|
const unavailableReason = getStatsMineCardUnavailableReason(result);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canMineSentence: unavailableReason === null,
|
||||||
|
canMineWordAudio: unavailableReason === null && exactMatch,
|
||||||
|
exactMatch,
|
||||||
|
unavailableReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSentenceWithMatches(text: string, query: string): ReactNode {
|
||||||
|
const ranges = findExactSentenceMatches(text, query);
|
||||||
|
if (ranges.length === 0) return text;
|
||||||
|
|
||||||
|
const parts: ReactNode[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
ranges.forEach((range, index) => {
|
||||||
|
if (range.start > cursor) {
|
||||||
|
parts.push(text.slice(cursor, range.start));
|
||||||
|
}
|
||||||
|
parts.push(
|
||||||
|
<mark
|
||||||
|
key={`${range.start}-${index}`}
|
||||||
|
className="rounded bg-ctp-yellow/15 px-0.5 text-ctp-yellow underline decoration-ctp-yellow/60 underline-offset-2"
|
||||||
|
>
|
||||||
|
{text.slice(range.start, range.end)}
|
||||||
|
</mark>,
|
||||||
|
);
|
||||||
|
cursor = range.end;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
parts.push(text.slice(cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
|
|||||||
import { groupSessionsByVideo } from './session-grouping';
|
import { groupSessionsByVideo } from './session-grouping';
|
||||||
|
|
||||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||||
|
const { sessionId, ...rest } = overrides;
|
||||||
return {
|
return {
|
||||||
sessionId: overrides.sessionId,
|
sessionId,
|
||||||
canonicalTitle: null,
|
canonicalTitle: null,
|
||||||
videoId: null,
|
videoId: null,
|
||||||
animeId: null,
|
animeId: null,
|
||||||
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
|
|||||||
yomitanLookupCount: 0,
|
yomitanLookupCount: 0,
|
||||||
knownWordsSeen: 0,
|
knownWordsSeen: 0,
|
||||||
knownWordRate: 0,
|
knownWordRate: 0,
|
||||||
...overrides,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ test('TabBar renders Library instead of Anime for the media library tab', () =>
|
|||||||
assert.doesNotMatch(markup, />Anime</);
|
assert.doesNotMatch(markup, />Anime</);
|
||||||
assert.match(markup, />Overview</);
|
assert.match(markup, />Overview</);
|
||||||
assert.match(markup, />Library</);
|
assert.match(markup, />Library</);
|
||||||
|
assert.match(markup, />Search</);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
|
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
|||||||
episodeCount: 3,
|
episodeCount: 3,
|
||||||
lastWatchedMs: 0,
|
lastWatchedMs: 0,
|
||||||
}}
|
}}
|
||||||
avgSessionMs={20_000}
|
|
||||||
knownWordsSummary={null}
|
knownWordsSummary={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
|
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
|
|||||||
reading: string;
|
reading: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatsCoverImage {
|
||||||
|
contentType: string;
|
||||||
|
dataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsCoverImagesData {
|
||||||
|
anime: Record<number, StatsCoverImage | null>;
|
||||||
|
media: Record<number, StatsCoverImage | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KanjiEntry {
|
export interface KanjiEntry {
|
||||||
kanjiId: number;
|
kanjiId: number;
|
||||||
kanji: string;
|
kanji: string;
|
||||||
@@ -105,6 +115,20 @@ export interface VocabularyOccurrenceEntry {
|
|||||||
occurrenceCount: number;
|
occurrenceCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchResult {
|
||||||
|
animeId: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
videoId: number;
|
||||||
|
videoTitle: string;
|
||||||
|
sourcePath: string | null;
|
||||||
|
secondaryText: string | null;
|
||||||
|
sessionId: number;
|
||||||
|
lineIndex: number;
|
||||||
|
segmentStartMs: number | null;
|
||||||
|
segmentEndMs: number | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OverviewData {
|
export interface OverviewData {
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
rollups: DailyRollup[];
|
rollups: DailyRollup[];
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/subminer-yomitan updated: ac01248fe1...99d6bf853c
Reference in New Issue
Block a user