Compare commits

..

1 Commits

Author SHA1 Message Date
6749ff843c feat(stats): add v1 immersion stats dashboard (#19) 2026-03-20 02:43:28 -07:00
221 changed files with 9577 additions and 2694 deletions

View File

@@ -1,5 +1,72 @@
# Changelog # Changelog
## v0.7.0 (2026-03-19)
### Added
- Immersion: Added Mine Word, Mine Sentence, and Mine Audio buttons to word detail example lines in the stats dashboard.
- Immersion: Mine Word creates a full Yomitan card (definition, reading, pitch accent) via the hidden search page bridge, then enriches with sentence audio, screenshot, and metadata extracted from the source video.
- Immersion: Mine Sentence and Mine Audio create cards directly with appropriate Lapis/Kiku flags, sentence highlighting, and media from the source file.
- Immersion: Media generation (audio + image/AVIF) runs in parallel and respects all AnkiConnect config options.
- Immersion: Added word exclusion list to the Vocabulary tab with localStorage persistence and a management modal.
- Immersion: Fixed truncated readings in the frequency rank table (e.g. お前 now shows おまえ instead of まえ).
- Immersion: Clicking a bar in the Top Repeated Words chart now opens the word detail panel.
- Immersion: Secondary subtitle text is now stored alongside primary subtitle lines for use as translation when mining cards from the stats page.
- Stats: Added `subminer stats -b` to start or reuse a dedicated background stats server without blocking normal SubMiner instances.
- Stats: Added `subminer stats -s` to stop the dedicated background stats server without closing browser tabs.
- Stats: Stats server startup now reuses a running background stats daemon instead of trying to bind a second local server in another SubMiner instance.
- Launcher: Added launcher passthrough for `-a/--args` so mpv receives raw extra launch flags (`--fs`, `--ytdl-format`, custom audio/video settings, etc.) from the `subminer` command.
- Launcher: Added `subminer stats` to launch the local stats dashboard, force-start the stats server on demand, and open the dashboard in your browser.
- Launcher: Added `subminer stats cleanup` to backfill vocabulary metadata and prune stale or excluded immersion rows on demand.
- Launcher: Added `stats.autoOpenBrowser` so browser launch after `subminer stats` can be enabled or disabled explicitly.
- Immersion: Added a local stats dashboard for immersion tracking with Overview, Anime, Trends, Vocabulary, and Sessions views.
- Immersion: Added anime progress, episode completion, Anki card links, and occurrence drill-down across the stats dashboard.
- Immersion: Added richer session timelines with new-word activity, cumulative totals, and pause/seek/card event markers.
- Immersion: Added completed-episodes and completed-anime totals to the Overview tracking snapshot.
### Changed
- Anki: Changed known-word cache settings to live under `ankiConnect.knownWords` instead of mixing them into `ankiConnect.nPlusOne`.
- Anki: Kept legacy `ankiConnect.nPlusOne` known-word keys and older `ankiConnect.behavior.nPlusOne*` keys as deprecated compatibility fallbacks.
- Stats: Added session deletion to the Sessions tab with the same confirmation prompt used by anime episode/session deletes, and removed all associated session rows from the stats database.
- Immersion: Kept immersion tracking history by default while preserving daily/monthly rollup maintenance.
- Immersion: Added exact lifetime summary reads for overview/anime/media stats so dashboard totals no longer depend on rescanning raw telemetry.
- Immersion: Reduced tracker storage overhead by removing duplicated subtitle text from subtitle-line event payloads.
- Immersion: Deduplicated episode cover-art blobs through a shared blob store and updated cover-art reads/writes to resolve shared images correctly.
- Immersion: Added indexes for large-history session, telemetry, vocabulary, kanji, and cover-art queries to keep dashboard reads fast as the SQLite database grows.
- Immersion: Renamed the stats dashboard's Anime tab to Library so the media browser label matches non-anime sources like YouTube and other yt-dlp-backed content.
- Anilist: Standardized episode completion threshold by introducing `DEFAULT_MIN_WATCH_RATIO` and using it for both local watched state transitions and AniList post-watch progress updates.
- Anilist: Episode auto-marking now uses the same threshold as AniList (`85%`), removing divergent completion behavior.
- Overlay: Excluded interjections and sound-effect tokens from subtitle annotation styling so they no longer inherit misleading lexical highlight treatment while still remaining visible and hoverable as plain subtitle tokens.
- Overlay: Expanded subtitle annotation noise filtering to also strip annotation metadata from standalone grammar-only helper tokens such as particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, and merged trailing quote-particle forms like `...って` while keeping them tokenized for hover lookup.
### Fixed
- Launcher: Fixed mpv Lua plugin binary auto-detection on Linux to also search `/usr/bin/subminer` and `/usr/local/bin/subminer` (lowercase), matching the conventional Unix wrapper name used by packaged installs such as the AUR package.
- Stats: Fixed the in-app stats overlay so it connects to the configured `stats.serverPort` instead of falling back to the default port.
- Overlay: Fixed subtitle frequency tagging for merged lookup-backed tokens like `陰に` by falling back to exact surface-form Yomitan frequencies when the normalized headword lookup misses.
- Overlay: Fixed MeCab merged-token position mapping across line breaks so merged content-plus-particle tokens like `陰に` keep their matched Yomitan frequency instead of inheriting shifted POS tags.
- Overlay: Fixed grouped frequency parsing in both Yomitan and fallback frequency-dictionary lookups so display values like `118,121` use the leading rank instead of collapsing the rank and occurrence count into `118121`.
- Overlay: Fixed frequency-rank ingestion to ignore Yomitan dictionaries explicitly marked `occurrence-based`, so raw occurrence counts are no longer treated as subtitle rank values.
- Overlay: Fixed inflected headword frequency tagging to prefer ranks from the selected Yomitan `termsFind` popup entry itself, ordered by configured dictionary priority, so forms like `潜み` use primary-dictionary ranks like `4073` before falling back to lower-priority raw lemma metadata such as `CC100`.
- Overlay: Fixed annotation-stage frequency filtering so exact kanji noun tokens like `者` keep their matched rank even when MeCab labels them `名詞/非自立`, instead of dropping the highlight after scan-time frequency lookup succeeds.
- Anki: Fixed repeated character-dictionary startup work by scheduling auto-sync only from mpv media-path changes instead of also re-triggering it from connection and media-title events for the same title.
- Overlay: Fixed macOS fullscreen overlay stability by keeping the passive visible overlay from stealing focus, re-raising the overlay window when reasserting its macOS topmost level, and tolerating one transient macOS tracker/helper miss before hiding the overlay.
- Overlay: Kept subtitle tokenization warmup one-shot for the lifetime of the app so later fullscreen/media churn on macOS does not replay the startup warmup gate after the first file is ready.
- Overlay: Added a bounded macOS tracker loss-grace window so fullscreen enter/leave transitions do not immediately hide and reload the overlay when the helper briefly loses the mpv window.
- Overlay: Skipped subtitle/tokenization refresh invalidation on character-dictionary auto-sync completion when the dictionary was already current, preventing startup flash/reload loops on unchanged media.
- Stats: Fixed session stats so known-word counts track real known-word occurrences without collapsing subtitle-line gaps.
- Stats: Fixed session word totals in session-facing stats views to prefer token counts when available, preventing known words from exceeding total words in the session chart.
- Stats: Fixed the stats Vocabulary tab blank-screen regression caused by a hook-order crash after vocabulary data finished loading.
- Anki: Fixed card-mine OSD feedback so the final mine result stops the Anki spinner first, then shows a single-line `✓`/`x` status without being overwritten by a later spinner tick.
- Stats: Removed the misleading `New words` series from expanded session charts; session detail now shows only the real total-word and known-word lines.
- Stats: Restored the cross-anime word table behavior in stats vocabulary surfaces so shared vocabulary entries no longer disappear or merge incorrectly across related media.
- Stats: `subminer stats -b` now runs as a standalone background stats daemon instead of reusing the main SubMiner app process, so the overlay app can still be launched separately for normal video watching.
- Stats: Dashboard word mining still works against the background daemon by using a short-lived hidden helper for the Yomitan add-note flow.
- Stats: Load full session timelines by default in stats session detail views so long sessions preserve complete telemetry history instead of being truncated by a fixed sample limit.
- Stats: Replaced heuristic stats word counts with Yomitan token counts, so session, media, anime, and trend subtitle totals now come directly from parsed subtitle tokens.
- Stats: Updated stats UI labels and lookup-rate copy to refer to tokens instead of words where those counts are shown.
- Overlay: Reduced repeated `Overlay loading...` popups on macOS when fullscreen tracker flaps briefly hide and recover the visible overlay.
- Stats: Scaled expanded session-detail known-word charts to the session's actual percentage range so small changes no longer render as a nearly flat line.
- Jlpt: Reduced JLPT dictionary startup log noise by summarizing duplicate surface-form collisions instead of logging one line per duplicate entry.
## v0.6.5 (2026-03-15) ## v0.6.5 (2026-03-15)
### Internal ### Internal

155
README.md
View File

@@ -1,16 +1,20 @@
<div align="center"> <div align="center">
<img src="assets/SubMiner.png" width="169" alt="SubMiner logo"> <img src="assets/SubMiner.png" width="140" alt="SubMiner logo">
<h1>SubMiner</h1>
<strong>Look up words, mine to Anki, and enrich cards with context — without leaving mpv.</strong> # SubMiner
<br /><br />
**Sentence-mine from mpv — look up words, one-key Anki export, immersion tracking.**
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)]() [![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)](https://github.com/ksyasuda/SubMiner)
[![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) [![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe)
[![AUR](https://img.shields.io/aur/version/subminer-bin)](https://aur.archlinux.org/packages/subminer-bin)
</div> </div>
<br /> ---
SubMiner is an Electron overlay for [mpv](https://mpv.io) that turns video into a sentence-mining workstation. Look up any word with [Yomitan](https://github.com/yomidevs/yomitan), mine it to Anki with one key, and track your immersion over time.
<div align="center"> <div align="center">
@@ -18,66 +22,42 @@
</div> </div>
<br />
## What it does
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation — look up any word with Yomitan, mine it to Anki with one key, and track your immersion progress over time.
## Features ## Features
### Dictionary Lookups While You Watch **Dictionary lookups** — Yomitan runs inside the overlay. Hover or navigate to any word for full dictionary popups without leaving mpv.
Yomitan runs directly inside the overlay. Hover over any word in the subtitles or navigate with keyboard/controller to get full dictionary popups without pausing or switching windows. **One-key Anki mining** — Press one key to create a card with the sentence, audio clip, screenshot, and machine translation from the exact playback moment.
### One-Key Anki Mining
Press a single key to send a word to Anki. SubMiner auto-fills the card with the sentence, audio clip, screenshot, and machine translation — all captured from the exact moment you looked it up.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="One-key Anki card creation — Yomitan popup with dictionary entry and mine button over annotated subtitles"> <img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan popup with dictionary entry and mine button over annotated subtitles in mpv">
</div> </div>
### Reading Annotations **Reading annotations** — Real-time subtitle annotations with N+1 targeting, frequency highlighting, JLPT tags, and a character name dictionary. Grammar-only tokens render as plain text.
Subtitles are annotated in real time with N+1 targeting, frequency-dictionary highlighting, JLPT level tags, and a character name dictionary for anime and manga proper nouns.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Subtitle annotations with frequency highlighting, JLPT underlines, known words, N+1 targets, and character names"> <img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency highlighting, JLPT underlines, known words, and N+1 targets">
<br/>
<img src="docs-site/public/screenshots/annotations-key.png" width="800" alt="Subtitle annotations with frequency highlighting, JLPT underlines, known words, N+1 targets, and character names">
</div> </div>
### Immersion Dashboard **Immersion dashboard** — Local stats dashboard with watch time, anime progress, vocabulary growth, mining throughput, and session history.
A local stats dashboard tracks your watch time, anime progress, vocabulary growth, mining throughput, and session history. Drill down into individual sessions or browse your full library.
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard — overview with watch time, cards mined, streaks, and tracking snapshot"> <img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard with watch time, cards mined, streaks, and tracking snapshot">
<br /><br />
<img src="docs-site/public/screenshots/stats-vocabulary.png" width="800" alt="Vocabulary tab — unique words, known words, top repeated words, and unmined word list">
<br /><br />
<!-- <img src="docs-site/public/screenshots/stats-library.png" width="800" alt="Library tab — anime grid with episode counts and watch time"> -->
</div> </div>
### External Integrations **Integrations** — AniList episode tracking, Jellyfin remote playback, Jimaku subtitle downloads, alass/ffsubsync, and an annotated websocket feed for external clients.
- **AniList** — Automatic episode progress tracking
- **Jellyfin** — Remote playback, cast device mode
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
- **Texthooker & API** — Custom texthooker page and annotated websocket feed for external clients
<div align="center"> <div align="center">
<img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page with annotated subtitle lines — known words, N+1 targets, character names, and frequency highlighting"> <img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page with annotated subtitle lines and frequency highlighting">
</div> </div>
## Quick start ---
### 1. Install ## Quick Start
**Arch Linux (AUR):** ### Install
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. It installs the packaged AppImage plus the `subminer` wrapper: <details>
<summary><b>Arch Linux (AUR)</b></summary>
```bash ```bash
paru -S subminer-bin paru -S subminer-bin
@@ -86,84 +66,75 @@ paru -S subminer-bin
Or manually: Or manually:
```bash ```bash
git clone https://aur.archlinux.org/subminer-bin.git git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makepkg -si
cd subminer-bin
makepkg -si
``` ```
**Linux (AppImage):** </details>
<details>
<summary><b>Linux (AppImage)</b></summary>
```bash ```bash
mkdir -p ~/.local/bin
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
&& chmod +x ~/.local/bin/SubMiner.AppImage && chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer && chmod +x ~/.local/bin/subminer
``` ```
> [!NOTE] > [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`. > The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
**macOS (DMG/ZIP):** download the latest packaged build from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`. </details>
**Windows (Installer/ZIP):** download the latest `SubMiner-<version>.exe` installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` installed and available on `PATH`. <details>
<summary><b>macOS / Windows / From source</b></summary>
**From source** — see [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source). **macOS** — Download the latest DMG/ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
### 2. Launch the app once **Windows** — Download the latest installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` on `PATH`.
**From source** — See [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
</details>
### First Launch
Run `SubMiner.AppImage` (Linux), `SubMiner.app` (macOS), or `SubMiner.exe` (Windows). On first launch, SubMiner starts in the tray, creates a default config, and opens a setup popup where you can install the mpv plugin and configure Yomitan dictionaries.
### Mine
```bash ```bash
# Linux subminer video.mkv # auto-starts overlay + resumes playback
SubMiner.AppImage subminer --start video.mkv # explicit overlay start (if plugin auto_start=no)
subminer stats # open the immersion dashboard
subminer stats -b # keep the stats daemon running in background
subminer stats -s # stop the dedicated stats daemon
subminer stats cleanup # repair/prune stored stats vocabulary rows
``` ```
On macOS, launch `SubMiner.app`. On Windows, launch `SubMiner.exe` from the Start menu or install directory. ---
On first launch, SubMiner:
- starts in the tray/background
- creates the default config directory and `config.jsonc`
- opens a compact setup popup
- can install the mpv plugin to the default mpv scripts location for you
- links directly to Yomitan settings so you can install dictionaries before finishing setup
### 3. Finish setup
- click `Install mpv plugin` if you want the default plugin auto-start flow
- click `Open Yomitan Settings` and install at least one dictionary
- click `Refresh status`
- click `Finish setup`
The mpv plugin step is optional. Yomitan must report at least one installed dictionary before setup can be completed.
### 4. Mine
```bash
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
subminer stats # open the local stats dashboard in your browser
```
## Requirements ## Requirements
| Required | Optional | | Required | Optional |
| ------------------------------------------ | -------------------------------------------------- | | ------------------------------------------------------ | ----------------------------- |
| `bun` (source builds, Linux `subminer`) | | | [`mpv`](https://mpv.io) with IPC socket | `yt-dlp` |
| `mpv` with IPC socket | `yt-dlp` | | `ffmpeg` | `guessit` (AniSkip detection) |
| `ffmpeg` | `guessit` (better AniSkip title/episode detection) | | `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` | | [`bun`](https://bun.sh) (source builds, Linux wrapper) | `chafa`, `ffmpegthumbnailer` |
| Linux: `hyprctl` or `xdotool` + `xwininfo` | `chafa`, `ffmpegthumbnailer` | | Linux: `hyprctl` or `xdotool` + `xwininfo` | |
| macOS: Accessibility permission | | | macOS: Accessibility permission | |
Windows builds use native window tracking and do not require the Linux compositor helper tools. Windows uses native window tracking and does not need the Linux compositor tools.
## Documentation ## Documentation
For full guides on configuration, Anki, Jellyfin, immersion tracking/stats, and more, see [docs.subminer.moe](https://docs.subminer.moe). The VitePress source for that site lives in [`docs-site/`](./docs-site/). Full guides on configuration, Anki, Jellyfin, immersion tracking, and more at **[docs.subminer.moe](https://docs.subminer.moe)**.
## Acknowledgments ## Acknowledgments
Built on the shoulders of [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary). Subtitles powered by [Jimaku.cc](https://jimaku.cc). Dictionary lookups via [Yomitan](https://github.com/yomidevs/yomitan), and JLPT tags from [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab). Built on [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary). Subtitles from [Jimaku.cc](https://jimaku.cc). Lookups via [Yomitan](https://github.com/yomidevs/yomitan). JLPT tags from [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab).
## License ## License

View File

@@ -1,10 +1,11 @@
--- ---
id: TASK-143 id: TASK-143
title: Keep character dictionary auto-sync non-blocking during startup title: Keep character dictionary auto-sync non-blocking during startup
status: Done status: In Progress
assignee: [] assignee:
- codex
created_date: '2026-03-09 01:45' created_date: '2026-03-09 01:45'
updated_date: '2026-03-18 05:28' updated_date: '2026-03-20 09:22'
labels: labels:
- dictionary - dictionary
- startup - startup
@@ -33,8 +34,20 @@ Keep character dictionary auto-sync running in parallel during startup without d
- [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves. - [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test for startup autoplay release surviving delayed mpv readiness or late subtitle refresh after dictionary sync.
2. Harden the autoplay-ready release path so paused startup keeps retrying until mpv is actually released or media changes, without resuming user-paused playback later.
3. Keep the existing character-dictionary revisit fixes and paused-startup OSD fixes aligned with the autoplay change, then run targeted runtime tests and typecheck.
<!-- SECTION:PLAN:END -->
## Implementation Notes ## Implementation Notes
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel. Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel.
2026-03-20: User reports startup remains paused after annotations/tokenization are visible and only resumes after character-dictionary generation/import finishes. Investigating autoplay-ready release regression vs dictionary sync completion refresh.
2026-03-20: Added startup autoplay retry-budget helper so paused startup retries cover the full plugin gate window instead of only ~2.8s. Verification: bun test src/main/runtime/startup-autoplay-release-policy.test.ts src/main/runtime/character-dictionary-auto-sync.test.ts src/main/runtime/startup-osd-sequencer.test.ts src/main/runtime/character-dictionary-auto-sync-completion.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist; runtime-compat verifier passed at .tmp/skill-verification/subminer-verify-20260320-022106-nM28Nk. Pending real installed-app/mpv validation.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,80 @@
---
id: TASK-169
title: Cut minor release v0.7.0 for stats and runtime polish
status: Done
assignee:
- codex
created_date: '2026-03-19 17:20'
updated_date: '2026-03-19 17:31'
labels:
- release
- docs
- minor
dependencies:
- TASK-168
references:
- package.json
- README.md
- docs/RELEASING.md
- docs-site/changelog.md
- CHANGELOG.md
- release/release-notes.md
priority: high
ordinal: 108000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare the next release cut as `v0.7.0`, keeping 0-ver semantics by rolling the accumulated stats/dashboard, launcher, overlay, and stability work into the next minor line instead of a `1.0.0` release.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Repository version metadata is updated to `0.7.0`.
- [x] #2 Root release-facing docs are refreshed for the `0.7.0` release cut.
- [x] #3 `CHANGELOG.md` and `release/release-notes.md` contain the committed `v0.7.0` section and consumed fragments are removed.
- [x] #4 Public changelog/docs surfaces reflect the new release.
- [x] #5 Release-prep verification is recorded.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Bump `package.json` to `0.7.0`.
2. Refresh release-facing docs: root `README.md`, release guide versioning note, and public docs changelog summary.
3. Run `bun run changelog:build --version 0.7.0` to commit release artifacts and consume pending fragments.
4. Run release-prep verification (`changelog`, typecheck, tests, docs build if docs-site changed).
5. Update this task with notes, verification, and final summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Bumped `package.json` from `0.6.5` to `0.7.0` and refreshed the root release-facing copy in `README.md` so the release prep explicitly calls out the new stats/dashboard line plus the background stats daemon commands. Updated `docs/RELEASING.md` with the repo's 0-ver versioning policy and an explicit `--date` reminder after the changelog generator initially stamped `2026-03-20` from UTC instead of the intended local release date `2026-03-19`.
Ran `bun run changelog:build --version 0.7.0`, which generated `CHANGELOG.md` and `release/release-notes.md` and removed the queued `changes/*.md` fragments for the accumulated stats, launcher, overlay, JLPT, and stability work. Added a curated `v0.7.0` summary to `docs-site/changelog.md` so the public docs changelog stays aligned with the committed root changelog while remaining user-facing.
Verification:
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `bun run changelog:lint`
- `bun run changelog:check --version 0.7.0`
- `bun run verify:config-example`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run docs:test`
- `bun run docs:build`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared minor release `v0.7.0` as the next 0-ver major line. Version metadata, root changelog, generated release notes, README release copy, release-guide policy, and the public docs changelog are now aligned for the release cut.
Docs update required: yes. Completed in `README.md`, `docs/RELEASING.md`, and `docs-site/changelog.md`.
Changelog fragment required: no new fragment for this task. Existing pending release fragments were consumed into the committed `v0.7.0` changelog section and `release/release-notes.md`.
Release-prep verification passed across changelog validation, config-example verification, typecheck, fast/env tests, full build, and docs-site test/build.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,64 @@
---
id: TASK-177.1
title: Fix overview lookup rate metric
status: Done
assignee:
- '@codex'
created_date: '2026-03-19 17:46'
updated_date: '2026-03-19 17:54'
labels:
- stats
- immersion-tracking
- yomitan
dependencies: []
references:
- stats/src/components/overview/OverviewTab.tsx
- stats/src/lib/dashboard-data.ts
- stats/src/lib/yomitan-lookup.ts
- src/core/services/immersion-tracker/query.ts
- src/core/services/stats-server.ts
parent_task_id: TASK-177
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update the stats homepage Tracking Snapshot so Lookup Rate reflects lifetime intentional Yomitan lookups normalized by total tokens seen, matching the newer stats semantics already used in session, media, and anime views.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Overview data exposes the lifetime totals needed to compute global Yomitan lookups per 100 tokens on the homepage
- [x] #2 The homepage Tracking Snapshot Lookup Rate card shows Yomitan lookup rate as `X / 100 tokens` with tooltip/copy aligned to that meaning
- [x] #3 Automated tests cover the lifetime totals plumbing and homepage summary/rendering change
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Extend overview lifetime hints/query plumbing to include total tokens seen and total intentional Yomitan lookups from finished sessions.
2. Add/adjust focused tests first for query hints, stats overview API typing/mocks, and overview summary formatting so the homepage metric fails under old semantics.
3. Update the overview summary/card to derive Lookup Rate from lifetime Yomitan lookups per 100 tokens and align tooltip/copy with that meaning.
4. Run focused verification on the touched query, stats-server, and stats UI tests; record results and blockers in the task notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Extended overview lifetime hints to include total tokens seen and total intentional Yomitan lookups from finished sessions so the homepage can compute a true global lookup rate.
Extracted the homepage Tracking Snapshot into a dedicated presentational component to keep OverviewTab smaller and make the Lookup Rate card copy directly testable.
Focused verification passed for query hints, IPC/stats overview plumbing, stats server overview response, dashboard summary logic, and homepage snapshot rendering.
SubMiner verifier core lane artifact: .tmp/skill-verification/subminer-verify-20260319-105320-7FDlwh. `bun run typecheck` passed there; `bun run test:fast` failed for a pre-existing/unrelated environment issue in scripts/update-aur-package.test.ts because scripts/update-aur-package.sh reported `mapfile: command not found`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Homepage Lookup Rate now uses lifetime intentional Yomitan lookups normalized by lifetime tokens seen, matching the existing session/media/anime semantics instead of the old known-word hit-rate metric. I extended overview query hints and API typings with total token and Yomitan lookup totals, updated the overview summary builder to reuse the shared per-100-token formatter, and replaced the inline Tracking Snapshot block with a dedicated component that renders `X / 100 tokens` plus Yomitan-specific tooltip copy.
Tests added/updated: query hints coverage for the new lifetime totals, stats server and IPC overview fixtures, overview summary assertions, and a dedicated Tracking Snapshot render test for the homepage card text. Focused `bun test` runs passed for those touched areas. Repo-native verifier `--lane core` also passed `bun run typecheck`; its `bun run test:fast` step still fails for the unrelated existing `scripts/update-aur-package.sh: line 71: mapfile: command not found` environment issue.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,62 @@
---
id: TASK-177.2
title: Count homepage new words by headword
status: Done
assignee:
- '@codex'
created_date: '2026-03-19 19:38'
updated_date: '2026-03-19 19:40'
labels:
- stats
- immersion-tracking
- vocabulary
dependencies: []
references:
- src/core/services/immersion-tracker/query.ts
- stats/src/components/overview/TrackingSnapshot.tsx
- stats/src/lib/dashboard-data.ts
parent_task_id: TASK-177
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Align the homepage New Words metric with the Known Words semantics by counting distinct headwords first seen in the selected window, so inflected or alternate forms of the same word do not inflate the summary.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Homepage new-word counts use distinct headwords by earliest first-seen timestamp instead of counting separate word-form rows
- [x] #2 Homepage tooltip/copy reflects the headword-based semantics
- [x] #3 Automated tests cover the headword de-duplication behavior and affected overview copy
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Change the new-word aggregate query to group `imm_words` by headword, compute each headword's earliest `first_seen`, and count headwords whose first sighting falls within today/week windows.
2. Add failing tests first for the aggregate path so multiple rows sharing a headword only contribute once.
3. Update homepage tooltip/copy to say unique headwords first seen today/week.
4. Run focused query and stats overview tests, then record verification and any blockers.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated the new-word aggregate to count distinct headwords by each headword's earliest `first_seen` timestamp, so multiple inflected/form rows for the same headword contribute only once.
Adjusted homepage tooltip copy to say unique headwords first seen today/week, keeping the visible card labels unchanged.
Focused verification passed for the query aggregate and homepage snapshot tests.
SubMiner verifier core lane artifact: .tmp/skill-verification/subminer-verify-20260319-123942-4intgW. `bun run typecheck` passed there; `bun run test:fast` still fails for the unrelated environment issue in scripts/update-aur-package.test.ts (`mapfile: command not found`).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Homepage New Words now uses headword-level semantics instead of counting separate `(headword, word, reading)` rows. The aggregate query groups `imm_words` by headword, uses each headword's earliest `first_seen`, and counts headwords first seen today or this week so alternate forms do not inflate the summary. The homepage tooltip copy now explicitly says the metric is based on unique headwords.
Added focused regression coverage for the de-duplication rule in `getQueryHints` and for the updated homepage tooltip text. Targeted `bun test` runs passed for the touched query and stats UI files. Repo verifier `--lane core` again passed `bun run typecheck`; `bun run test:fast` remains blocked by the unrelated existing `scripts/update-aur-package.sh: line 71: mapfile: command not found` failure.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,64 @@
---
id: TASK-177.3
title: Fix attached stats command flow and browser config
status: Done
assignee:
- '@codex'
created_date: '2026-03-19 20:15'
updated_date: '2026-03-19 20:17'
labels:
- launcher
- stats
- cli
dependencies: []
references:
- launcher/commands/stats-command.ts
- launcher/commands/command-modules.test.ts
- launcher/main.test.ts
- src/main/runtime/stats-cli-command.ts
- src/main/runtime/stats-cli-command.test.ts
parent_task_id: TASK-177
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make `subminer stats` stay attached to the foreground app process instead of routing through daemon startup, while keeping background/stop behavior on the daemon path. Ensure browser opening for stats respects only `stats.autoOpenBrowser` in the normal stats flow.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Default `subminer stats` forwards through the attached foreground stats command path instead of the daemon-start path
- [x] #2 `subminer stats --background` and `subminer stats --stop` continue using the daemon control path
- [x] #3 Normal stats launches do not open a browser when `stats.autoOpenBrowser` is false, and automated tests cover the launcher/runtime regressions
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing launcher tests first so default `stats` expects `--stats` forwarding while `--background` and `--stop` continue to expect daemon control flags.
2. Add/adjust runtime stats command tests to prove `stats.autoOpenBrowser=false` suppresses browser opening on the normal attached stats path.
3. Patch launcher forwarding logic in `launcher/commands/stats-command.ts` to choose foreground vs daemon flags correctly without changing cleanup handling.
4. Run targeted launcher and stats runtime tests, then record verification results and blockers.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Confirmed root cause: launcher default `stats` flow always forwarded `--stats-daemon-start` plus `--stats-daemon-open-browser`, which detached the terminal process and bypassed `stats.autoOpenBrowser` because browser opening happened in daemon control instead of the normal stats CLI handler.
Updated launcher forwarding so plain `subminer stats` now uses the attached `--stats` path, while explicit `--background` and `--stop` continue using daemon control flags.
Added launcher regression coverage for the attached/default path and preserved background/stop expectations; added runtime coverage proving `stats.autoOpenBrowser=false` suppresses browser opening on the normal stats path.
Verifier passed for `launcher-plugin` and `runtime-compat` lanes. Artifact: .tmp/skill-verification/subminer-verify-20260319-131703-ZaAaUV.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed `subminer stats` so the default command now forwards to the normal attached `--stats` app path instead of the daemon-start path. That keeps the foreground process attached to the terminal as expected, while `subminer stats --background` and `subminer stats --stop` still use daemon control. Because the normal stats CLI path already respects `config.stats.autoOpenBrowser`, this also fixes the unwanted browser-open behavior that previously bypassed config via `--stats-daemon-open-browser`.
Added launcher command and launcher integration regressions for the new forwarding behavior, plus a runtime stats CLI regression that asserts `stats.autoOpenBrowser=false` suppresses browser opening. Verification passed with targeted launcher tests, targeted runtime stats tests, and the SubMiner verifier `launcher-plugin` + `runtime-compat` lanes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,66 @@
---
id: TASK-182.2
title: Improve session detail known-word chart scaling
status: Done
assignee:
- codex
created_date: '2026-03-19 20:31'
updated_date: '2026-03-19 20:52'
labels:
- bug
- stats
- ui
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/stats/src/components/sessions/SessionDetail.tsx
- >-
/Users/sudacode/projects/japanese/SubMiner/stats/src/lib/session-detail.test.tsx
parent_task_id: TASK-182
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Adjust the expanded session-detail known-word percentage chart so the vertical range reflects the session's actual percent range instead of always spanning 0-100. Keep the chart easier to read while preserving the percent-based tooltip/legend behavior already used in the stats UI.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Expanded session detail scales the known/unknown percent chart to the session's observed percent range instead of hard-coding a 0-100 top bound
- [x] #2 The chart keeps a small headroom above the highest observed known-word percent so the line remains visually readable near the top edge
- [x] #3 Automated frontend coverage locks the new percent-domain behavior and preserves existing session-detail rendering
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused frontend regression test for the session-detail ratio chart domain calculation, covering a session whose known-word percentage stays in a narrow band below 100% and expecting a dynamic top bound with headroom.
2. Update `stats/src/components/sessions/SessionDetail.tsx` to compute a dynamic percent-axis domain and matching ticks for the ratio chart, keeping the lower bound at 0%, adding modest padding above the highest known percentage, rounding to clean tick steps, and capping at 100%.
3. Apply the computed percent-axis bounds consistently to the right-side Y axis and the session chart pause overlays so the visual framing stays aligned.
4. Run targeted frontend tests and the SubMiner verification helper on the touched files, then record results and any blockers in the task.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented dynamic known-percentage axis scaling in `stats/src/components/sessions/SessionDetail.tsx`: the ratio chart now keeps a 0% floor, uses the highest observed known percentage plus 5 points of headroom for the top bound, rounds that bound up to clean 10-point ticks, caps at 100%, and enables `allowDataOverflow` so the stacked area chart actually honors the tighter domain.
Added frontend regression coverage in `stats/src/lib/session-detail.test.tsx` for the axis-max helper, covering both a narrow-band session and near-100% cap behavior.
Added user-visible changelog fragment `changes/2026-03-19-session-detail-chart-scaling.md`.
Verification: `bun test stats/src/lib/session-detail.test.tsx` passed; `bun run typecheck` passed; `bun run changelog:lint` passed; `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core stats/src/components/sessions/SessionDetail.tsx stats/src/lib/session-detail.test.tsx` ran and passed `typecheck` but failed `bun run test:fast` on a pre-existing unrelated issue in `scripts/update-aur-package.test.ts` / `scripts/update-aur-package.sh` (`mapfile: command not found`). Artifacts: `.tmp/skill-verification/subminer-verify-20260319-134440-JRHAUJ`.
Docs decision: no internal docs update required; the behavior change is localized UI presentation with no API/workflow change. Changelog decision: yes, required and completed because the fix is user-visible.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Improved expanded session-detail chart readability by replacing the fixed 0-100 known-word percentage axis with a dynamic top bound based on the sessions highest observed known percentage plus modest headroom, rounded to clean ticks and capped at 100%. The ratio chart now also enables `allowDataOverflow` so Recharts preserves the tighter percent domain even though the stacked known/unknown areas sum to 100%.
Added frontend regression coverage for the new axis-max behavior and a changelog fragment for the user-visible stats fix.
Verification: `bun test stats/src/lib/session-detail.test.tsx`, `bun run typecheck`, and `bun run changelog:lint` passed. The SubMiner verification helpers `core` lane also passed `typecheck`, but `bun run test:fast` remains red on a pre-existing unrelated bash-compat failure in `scripts/update-aur-package.test.ts` / `scripts/update-aur-package.sh` (`mapfile: command not found`).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,67 @@
---
id: TASK-192
title: Fix stale anime cover art after AniList reassignment
status: Done
assignee:
- codex
created_date: '2026-03-20 00:12'
updated_date: '2026-03-20 00:14'
labels:
- stats
- immersion-tracker
- anilist
milestone: m-1
dependencies: []
references:
- src/core/services/immersion-tracker-service.ts
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker-service.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the stats anime-detail cover image path so reassigning an anime to a different AniList entry replaces the stored cover art bytes instead of keeping the previous image blob under updated metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Reassigning an anime to a different AniList entry stores the new cover art bytes for that anime's videos
- [x] #2 Shared blob deduplication still works when multiple videos in the anime use the same new cover image
- [x] #3 Focused regression coverage proves stale cover blobs are replaced on reassignment
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a failing regression test that reassigns an anime twice with different downloaded cover bytes and asserts the resolved cover updates.
2. Update cover-art upsert logic so new blob bytes generate a new shared hash instead of reusing an existing hash for the row.
3. Run the focused immersion tracker service test file and record the result.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-20: Created during live debugging of a user-reported stale anime profile picture after changing the AniList entry from the stats UI.
2026-03-20: Root cause was in `upsertCoverArt(...)`. When a row already had `cover_blob_hash`, a later AniList reassignment with a freshly downloaded cover reused the existing hash instead of hashing the new bytes, so the blob store kept serving the old image while metadata changed.
2026-03-20: Added a regression in `src/core/services/immersion-tracker-service.test.ts` that reassigns the same anime twice with different fetched image bytes and asserts the resolved anime cover changes to the second blob while both videos still deduplicate to one shared hash.
2026-03-20: Fixed `src/core/services/immersion-tracker/query.ts` so incoming cover blob bytes compute a fresh hash before falling back to an existing row hash. Existing hashes are now reused only when no new bytes were fetched.
2026-03-20: Verification commands run:
- `bun test src/core/services/immersion-tracker-service.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
2026-03-20: Verification results:
- focused service test: passed
- verifier lane selection: `core`
- verifier result: passed (`bun run typecheck`, `bun run test:fast`)
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260320-001433-IZLFqs/`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed stale anime cover art after AniList reassignment by correcting cover-blob hash replacement in the immersion tracker storage layer. Reassignments now store the new fetched image bytes instead of reusing the previous blob hash from the row, while still deduplicating the updated image across videos in the same anime.
Added focused regression coverage that reproduces the exact failure mode: same anime reassigned twice with different cover downloads, with the second image expected to replace the first. Verified with the touched service test file plus the SubMiner `core` verification lane.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,64 @@
---
id: TASK-195
title: Keep final card-mine OSD result from being overwritten by progress spinner
status: Done
assignee:
- Codex
created_date: '2026-03-18 19:40'
updated_date: '2026-03-18 19:49'
labels:
- anki
- ui
- bug
milestone: m-1
dependencies: []
references:
- src/anki-integration/ui-feedback.ts
- src/anki-integration.ts
- src/anki-integration/card-creation.ts
priority: medium
ordinal: 105610
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When a card mine finishes, the mpv OSD currently tries to show the final status text but the in-flight Anki progress spinner can immediately overwrite it on the next tick. Stop the spinner first, then show a single-line final result with a success/failure marker and the mined-word notification.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Successful mine/update OSD results render after the spinner is stopped and do not get overwritten by a later spinner tick.
- [x] #2 Failure results that replace the spinner show an `x` marker and stay visible on the same OSD line.
- [x] #3 Regression coverage locks the spinner teardown/result-notification ordering.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused failing regression test around the Anki UI-feedback spinner/result helper.
2. Add a helper that stops progress before emitting the final OSD result line with `✓`/`x`.
3. Route mine/update result notifications through that helper, then run targeted verification.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Added a dedicated Anki UI-feedback result helper that force-clears the in-flight spinner state before emitting the final OSD result line. Successful card-update notifications now render as `✓ Updated card: ...`, and sentence-card creation failures now render as `x Sentence card failed: ...` without a later spinner tick reclaiming the line.
Verification:
- `bun test src/anki-integration/ui-feedback.test.ts`
- `bun test src/anki-integration/ui-feedback.test.ts src/anki-integration/note-update-workflow.test.ts src/anki-integration.test.ts src/core/services/mining.test.ts src/main/runtime/mining-actions.test.ts`
- `bun x prettier --check src/anki-integration/ui-feedback.ts src/anki-integration/ui-feedback.test.ts src/anki-integration.ts src/anki-integration/card-creation.ts "backlog/tasks/task-195 - Keep-final-card-mine-OSD-result-from-being-overwritten-by-progress-spinner.md" changes/2026-03-18-mine-osd-spinner-result.md`
- `bun run changelog:lint`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/anki-integration/ui-feedback.ts src/anki-integration/ui-feedback.test.ts src/anki-integration.ts src/anki-integration/card-creation.ts changes/2026-03-18-mine-osd-spinner-result.md`
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260318-194614-uZMrAx/`
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-196
title: Fix subtitle prefetch cache-key mismatch and active-cue window
status: Done
assignee: []
created_date: '2026-03-18 16:05'
labels: []
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts
documentation: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix file-backed subtitle annotation latency where prefetch should warm upcoming lines but live playback still tokenizes each subtitle line. Likely causes: cache-key mismatch between parsed cue text and mpv `sub-text`, and priority-window selection skipping the currently active cue during mid-line starts/seeks.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Prefetched subtitle entries are reused when live subtitle text differs only by normalization details such as ASS `\N`, newline collapsing, or surrounding whitespace.
- [x] #2 Priority-window selection includes the currently active cue when playback starts or seeks into the middle of a cue.
- [x] #3 Regression tests cover the cache-hit normalization path and active-cue priority-window behavior.
- [x] #4 Verification covers the touched prefetch/controller lane.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression tests in `subtitle-processing-controller.test.ts` and `subtitle-prefetch.test.ts`.
2. Normalize cache keys in the subtitle processing controller so prefetch/live paths share keys.
3. Adjust prefetch priority-window selection to include the active cue.
4. Run targeted tests, then SubMiner verification lane for touched files.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Normalized subtitle cache keys inside the processing controller so prefetched ASS/VTT/live subtitle text variants reuse the same cache entry, and changed priority-window selection to include the currently active cue based on cue end time. Added regression coverage for both paths and verified the change with the `core` lane.
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-197
title: Eliminate per-line plain subtitle flash on prefetch cache hit
status: Done
assignee: []
created_date: '2026-03-18 16:28'
labels: []
dependencies:
- TASK-196
references:
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts
documentation: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove the remaining small per-line subtitle annotation delay after prefetch warmup by avoiding the unconditional plain-subtitle broadcast on mpv subtitle-change events when a cached annotated payload already exists.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 On a subtitle cache hit, the mpv subtitle-change path can emit annotated subtitle payload synchronously instead of first broadcasting `tokens: null`.
- [x] #2 Cache-miss behavior still preserves immediate plain-text subtitle display while async tokenization runs.
- [x] #3 Regression tests cover the controller cache-consume path and the mpv subtitle-change handler cache-hit branch.
- [x] #4 Verification covers the touched core/runtime lane.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing tests for controller cache consumption and mpv subtitle-change immediate annotated emission.
2. Add a controller method that consumes cached subtitle payload synchronously while updating internal latest/emitted state.
3. Wire the mpv subtitle-change handler to use the immediate cached payload when present, falling back to the existing plain-text path on misses.
4. Run focused tests and the cheapest sufficient verification lane.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Added `consumeCachedSubtitle` to the subtitle processing controller so cache hits can be claimed synchronously without reprocessing, then wired the mpv subtitle-change handler to emit cached annotated payloads immediately while preserving the existing plain-text fallback for misses. Verified with focused unit tests plus the `runtime-compat` lane.
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-199
title: Forward launcher log level into mpv plugin script opts
status: Done
assignee: []
created_date: '2026-03-18 21:16'
labels: []
dependencies:
- TASK-198
references:
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.ts
- /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts
- /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts
documentation: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make `subminer --log-level=debug ...` reach the mpv plugin auto-start path by forwarding the launcher log level into `--script-opts`, so plugin-started overlay and texthooker subprocesses inherit debug logging.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launcher mpv playback includes `subminer-log_level=<level>` in `--script-opts` when a non-info CLI log level is used.
- [x] #2 Detached idle mpv launch uses the same script-opt forwarding.
- [x] #3 Regression tests cover launcher script-opt forwarding.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a failing launcher regression test that captures mpv argv and expects `subminer-log_level=debug` inside `--script-opts`.
2. Extend the shared script-opt builder to accept launcher log level and emit `subminer-log_level` for non-info runs.
3. Reuse that builder in both normal mpv playback and detached idle mpv launch.
4. Run focused launcher tests and launcher-plugin verification.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Forwarded launcher log level into mpv plugin script opts via the shared builder and reused that builder for idle mpv launch. `subminer --log-level=debug ...` now gives the plugin `opts.log_level=debug`, so auto-started overlay and texthooker subprocesses include `--log-level debug` and the tokenizer timing logs can actually appear in the app log.
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,91 @@
---
id: TASK-200
title: 'Address latest PR #19 CodeRabbit follow-ups'
status: Done
assignee:
- '@codex'
created_date: '2026-03-19 07:18'
updated_date: '2026-03-19 07:28'
labels:
- pr-review
- anki-integration
- launcher
milestone: m-1
dependencies: []
references:
- launcher/mpv.test.ts
- src/anki-integration.ts
- src/anki-integration/card-creation.ts
- src/anki-integration/runtime.ts
- src/anki-integration/known-word-cache.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Validate the latest 2026-03-19 CodeRabbit review round on PR #19, implement only the confirmed fixes, and verify the touched launcher and Anki integration paths.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Each latest-round PR #19 CodeRabbit inline comment is validated against the current branch and classified as actionable or not warranted
- [x] #2 Confirmed correctness issues in launcher and Anki integration code are fixed with focused regression coverage where practical
- [x] #3 Targeted verification runs for the touched areas and the task notes record what changed versus what was rejected
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Validate the five inline comments from the 2026-03-19 CodeRabbit PR #19 review against current launcher and Anki integration code.
2. Add or extend focused tests for any confirmed launcher env-sandbox, notification-state, AVIF lead-in propagation, or known-word-cache lifecycle/scope regressions.
3. Apply the smallest safe fixes in `launcher/mpv.test.ts`, `src/anki-integration.ts`, `src/anki-integration/card-creation.ts`, `src/anki-integration/runtime.ts`, and `src/anki-integration/known-word-cache.ts` as needed.
4. Run targeted unit tests plus the SubMiner verification helper on the touched files, then record which comments were accepted or rejected in task notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Validated the five latest inline comments from CodeRabbit review `3973222927` on PR #19.
Accepted fixes:
- Hardened the three `findAppBinary` launcher tests against host leakage by sandboxing `SUBMINER_APPIMAGE_PATH` / `SUBMINER_BINARY_PATH` and stubbing executable checks so `/opt` and PATH resolution are deterministic.
- `showNotification()` now marks OSD/both updates as failed when `errorSuffix` is present instead of always rendering a success marker.
- `applyRuntimeConfigPatch()` now avoids starting or stopping known-word cache lifecycle work while the runtime is stopped, while still clearing cached state when highlighting is disabled.
- Extracted shared known-word cache lifecycle helpers and switched the persisted cache identity to the same lifecycle config used by runtime restart detection, so changes to `fields.word`, per-deck field mappings, or refresh interval invalidate stale cache state correctly.
Rejected fix:
- The `createSentenceCard()` AVIF lead-in comment was technically incomplete for this branch. There is no current caller that computes an `animatedLeadInSeconds` input for sentence-card creation, and the existing lead-in resolver depends on note media fields that do not exist before the new card's media is generated.
Regression coverage added:
- `src/anki-integration.test.ts` partial-failure OSD result marker.
- `src/anki-integration/runtime.test.ts` stopped-runtime known-word lifecycle guards.
- `src/anki-integration/known-word-cache.test.ts` cache invalidation when `fields.word` or per-deck field mappings change.
Verification:
- `bun test src/anki-integration/runtime.test.ts`
- `bun test src/anki-integration/known-word-cache.test.ts`
- `bun test src/anki-integration.test.ts --test-name-pattern 'marks partial update notifications as failures in OSD mode'`
- `bun test launcher/mpv.test.ts --test-name-pattern 'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists|findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist|findAppBinary finds subminer on PATH when AppImage candidates do not exist'`
- `bun test src/anki-integration.test.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts launcher/mpv.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh launcher/mpv.test.ts src/anki-integration.ts src/anki-integration/runtime.ts src/anki-integration/known-word-cache.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts src/anki-integration.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane launcher-plugin --lane core launcher/mpv.test.ts src/anki-integration.ts src/anki-integration/runtime.ts src/anki-integration/known-word-cache.ts src/anki-integration/runtime.test.ts src/anki-integration/known-word-cache.test.ts src/anki-integration.test.ts`
Verifier result:
- `launcher-plugin` lane passed (`test:launcher:smoke:src`, `test:plugin:src`).
- `core/typecheck` passed.
- `core/test-fast` failed for an unrelated existing environment issue in `scripts/update-aur-package.test.ts`: `scripts/update-aur-package.sh: line 71: mapfile: command not found` under the local macOS Bash environment.
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260319-002617-UgpKUy`
Classification: actionable and fixed -> `launcher/mpv.test.ts` env leakage hardening, `src/anki-integration.ts` partial-failure OSD marker, `src/anki-integration/runtime.ts` started-guard for known-word lifecycle calls, `src/anki-integration/known-word-cache.ts` cache identity alignment with runtime lifecycle config.
Classification: not warranted as written -> `src/anki-integration/card-creation.ts` lead-in threading comment. No current `createSentenceCard()` caller computes or owns an `animatedLeadInSeconds` value, and the existing lead-in helper derives from preexisting note media fields, so blindly adding an optional parameter would not fix a real branch behavior bug.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed four confirmed PR #19 latest-round CodeRabbit issues locally: deterministic launcher `findAppBinary` tests, correct partial-failure OSD result markers, started-state guards around known-word cache lifecycle restarts, and shared known-word cache identity logic so field-mapping changes invalidate stale cache state. Added focused regression coverage for each confirmed behavior.
One comment was intentionally not applied: the `createSentenceCard()` AVIF lead-in suggestion does not match the current branch architecture because no caller computes that value today and the existing resolver requires preexisting note media fields. Verification is green for all touched targeted tests plus the launcher-plugin/core typecheck lanes; the only remaining red is an unrelated existing `test:fast` failure in `scripts/update-aur-package.test.ts` caused by `mapfile` being unavailable in the local Bash environment.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,66 @@
---
id: TASK-201
title: Suppress repeated macOS overlay loading OSD during fullscreen tracker flaps
status: Done
assignee:
- '@codex'
created_date: '2026-03-19 18:47'
updated_date: '2026-03-19 19:01'
labels:
- bug
- macos
- overlay
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main/state.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.test.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce macOS fullscreen annoyance where the visible overlay briefly loses tracking and re-shows the `Overlay loading...` OSD even though the overlay runtime is already initialized and no new instance is launching. Keep the first startup/loading feedback, but suppress repeat loading notifications caused by subsequent tracker churn during fullscreen enter/leave or focus flaps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The first macOS visible-overlay load still shows the existing `Overlay loading...` OSD when tracker data is not yet ready.
- [x] #2 Repeated macOS tracker flaps after the overlay has already recovered do not immediately re-show `Overlay loading...` on every loss/recovery cycle.
- [x] #3 Focused regression tests cover the repeated tracker-loss/recovery path and preserve the initial-load notification behavior.
- [x] #4 The change does not alter overlay runtime bootstrap or single-instance behavior; only notification suppression behavior changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused failing regressions in `src/core/services/overlay-visibility.test.ts` that preserve the first macOS `Overlay loading...` OSD and suppress an immediate second OSD after tracker recovery/loss churn.
2. Extend the overlay-visibility state/runtime plumbing with a small macOS loading-OSD suppression state so tracker flap retries can be rate-limited without touching overlay bootstrap or single-instance logic.
3. Reset the suppression when the user explicitly hides the visible overlay so intentional hide/show retries can still surface first-load feedback.
4. Run focused verification for the touched overlay visibility/runtime tests and update the task with results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added optional loading-OSD suppression hooks to `src/core/services/overlay-visibility.ts` so macOS can rate-limit repeated `Overlay loading...` notifications without changing overlay bootstrap behavior.
Implemented service-local suppression state in `src/main/overlay-visibility-runtime.ts` with a 30s cooldown and explicit reset when the visible overlay is manually hidden, so fullscreen tracker flaps stay quiet but intentional hide/show retries can still show loading feedback.
Added focused regressions in `src/core/services/overlay-visibility.test.ts` for `loss -> recover -> immediate loss` suppression and for manual hide resetting suppression.
Verification: `bun test src/core/services/overlay-visibility.test.ts`; `bun test src/main/runtime/overlay-visibility-runtime-main-deps.test.ts src/main/runtime/overlay-visibility-runtime.test.ts`; `bun run typecheck`; `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane runtime-compat src/core/services/overlay-visibility.ts src/main/overlay-visibility-runtime.ts src/core/services/overlay-visibility.test.ts` -> passed. Real-runtime lane skipped: change is notification suppression logic and cheap/runtime-compat coverage was sufficient for this scoped behavior change; no live mpv/macOS fullscreen session was run in this turn.
Docs update required: no. Changelog fragment required: yes; added `changes/2026-03-19-overlay-loading-osd-fullscreen-flaps.md`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Reduced repeated macOS `Overlay loading...` popups caused by fullscreen tracker flap churn without touching overlay bootstrap or single-instance behavior. `src/core/services/overlay-visibility.ts` now accepts optional suppression hooks around the loading OSD path, and `src/main/overlay-visibility-runtime.ts` uses service-local state to rate-limit that OSD for 30 seconds while resetting the suppression when the visible overlay is explicitly hidden. Added focused regressions in `src/core/services/overlay-visibility.test.ts` to preserve the first-load notification, suppress immediate repeat notifications after tracker recovery/loss churn, and keep manual hide/show retries able to surface the loading OSD again. Added changelog fragment `changes/2026-03-19-overlay-loading-osd-fullscreen-flaps.md`. Verification passed with targeted overlay tests, typecheck, and the `runtime-compat` verifier lane; live macOS/mpv fullscreen runtime validation was not run in this turn.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,70 @@
---
id: TASK-202
title: Use ended session media position for anime episode progress
status: Done
assignee:
- Codex
created_date: '2026-03-19 14:55'
updated_date: '2026-03-19 17:36'
labels:
- stats
- ui
- bug
milestone: m-1
dependencies: []
references:
- stats/src/components/anime/EpisodeList.tsx
- stats/src/types/stats.ts
- src/core/services/immersion-tracker/session.ts
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker/storage.ts
priority: medium
ordinal: 105720
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The anime episode list currently computes the `Progress` column from cumulative `totalActiveMs / durationMs`, which can exceed the intended watch-position meaning after rewatches or repeated sessions. Persist the playback position at the time a session ends and drive episode progress from that stored stop position instead.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Session finalization persists the playback position reached when the session ended.
- [x] #2 Anime episode queries expose the most recent ended-session media position for each episode.
- [x] #3 Episode-list progress renders from ended media position instead of cumulative active watch time.
- [x] #4 Regression coverage locks storage/query/UI behavior for the new progress source.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression coverage for persisted ended media position and episode progress rendering.
2. Add `ended_media_ms` to the immersion-session schema and persist `lastMediaMs` when ending a session.
3. Thread the new field through episode queries/types and render episode progress from `endedMediaMs / durationMs`.
4. Run targeted verification plus typecheck, then record the outcome.
<!-- SECTION:PLAN:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Added nullable `ended_media_ms` storage to immersion sessions, persisted `lastMediaMs` when sessions finalize, and exposed the most recent ended-session media position through anime episode queries/types. The anime episode list now renders `Progress` from `endedMediaMs / durationMs` instead of cumulative active watch time, so rewatches no longer inflate the displayed percentage.
Verification:
- `bun test src/core/services/immersion-tracker/storage-session.test.ts`
- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`
- `bun test stats/src/lib/yomitan-lookup.test.tsx stats/src/lib/stats-ui-navigation.test.tsx`
- `bun run typecheck`
- `bun run changelog:lint`
- `bun x prettier --check 'src/core/services/immersion-tracker/types.ts' 'src/core/services/immersion-tracker/storage.ts' 'src/core/services/immersion-tracker/session.ts' 'src/core/services/immersion-tracker/query.ts' 'src/core/services/immersion-tracker/storage-session.test.ts' 'src/core/services/immersion-tracker/__tests__/query.test.ts' 'stats/src/types/stats.ts' 'stats/src/components/anime/EpisodeList.tsx' 'stats/src/lib/yomitan-lookup.test.tsx' 'stats/src/lib/stats-ui-navigation.test.tsx' 'backlog/tasks/task-202 - Use-ended-session-media-position-for-anime-episode-progress.md' 'changes/2026-03-19-stats-ended-media-progress.md'`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core 'src/core/services/immersion-tracker/types.ts' 'src/core/services/immersion-tracker/storage.ts' 'src/core/services/immersion-tracker/session.ts' 'src/core/services/immersion-tracker/query.ts' 'src/core/services/immersion-tracker/storage-session.test.ts' 'src/core/services/immersion-tracker/__tests__/query.test.ts' 'stats/src/types/stats.ts' 'stats/src/components/anime/EpisodeList.tsx' 'stats/src/lib/yomitan-lookup.test.tsx' 'stats/src/lib/stats-ui-navigation.test.tsx' 'backlog/tasks/task-202 - Use-ended-session-media-position-for-anime-episode-progress.md' 'changes/2026-03-19-stats-ended-media-progress.md'`
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260319-173511-AV7kUg/`
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-203
title: Restore known and JLPT annotation for reading-mismatch subtitle tokens
status: Done
assignee:
- Codex
created_date: '2026-03-19 18:25'
updated_date: '2026-03-19 18:25'
labels:
- subtitle
- bug
dependencies: []
references:
- src/core/services/tokenizer/annotation-stage.ts
- src/core/services/tokenizer/annotation-stage.test.ts
priority: medium
ordinal: 105721
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Some subtitle tokens lose both known-word coloring and JLPT underline even though the popup resolves a valid dictionary term. Repro example: `大体` in `大体 僕だって困ってたんですよ!` can be known via kana-only Anki data (`だいたい`) while JLPT lookup should still resolve from the kanji surface/headword.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Subtitle annotation can mark a token known via its reading when the configured headword/surface lookup misses.
- [x] #2 JLPT eligibility no longer drops valid kanji terms just because their reading contains repeated kana patterns.
- [x] #3 Regression coverage locks the combined known + JLPT case for `大体`.
<!-- AC:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Known-word annotation now falls back to the token reading after the configured headword/surface lookup misses, so kana-only known-card entries still light up matching subtitle tokens. JLPT eligibility now ignores repeated-kana noise checks on the reading when a real surface/headword is present, which preserves JLPT tagging for words like `大体`.
Verification:
- `bun test src/core/services/tokenizer/annotation-stage.test.ts`
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,60 @@
---
id: TASK-204
title: Make known-word cache incremental and avoid full rebuilds
status: Done
assignee:
- Codex
created_date: '2026-03-19 19:05'
updated_date: '2026-03-19 19:12'
labels:
- anki
- cache
- performance
dependencies: []
references:
- src/anki-integration/known-word-cache.ts
- src/anki-integration.ts
- src/config/resolve/anki-connect.ts
- src/config/definitions/defaults-integrations.ts
priority: high
ordinal: 105722
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the known-word cache rebuild behavior with incremental synchronization. Startup should load existing cache state without immediately pulling all tracked Anki notes. Config-timed sync should reconcile adds, deletes, and in-place field edits against cached per-note state. Mined cards should optionally append their extracted words immediately after mining, enabled by default. Full rebuild should remain available only through explicit doctor tooling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Known-word cache startup no longer performs an automatic full rebuild.
- [x] #2 Config-timed sync incrementally reconciles note additions, deletions, and edited word fields for the tracked known-word deck scope.
- [x] #3 Newly mined cards update the known-word cache immediately when the new config flag is enabled, and skip that fast path when disabled.
- [x] #4 Persisted cache state remains usable by stats endpoints that read the `words` set from disk.
- [x] #5 Regression tests cover startup behavior, incremental sync diffs, and the new config flag.
<!-- AC:END -->
## Outcome
<!-- SECTION:OUTCOME:BEGIN -->
Known-word cache startup now loads persisted state and schedules sync based on refresh timing instead of wiping and rebuilding immediately. Persisted cache state now includes per-note word snapshots so timed refreshes can remove deleted notes, update edited notes, and keep the global `words` set stable for stats consumers. Added `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`, so newly mined cards can update the cache immediately without waiting for the next timed sync.
Verification:
- `bun test src/anki-integration/known-word-cache.test.ts`
- `bun test src/config/resolve/anki-connect.test.ts src/config/config.test.ts`
- `bun test src/anki-integration.test.ts src/anki-integration/runtime.test.ts src/core/services/__tests__/stats-server.test.ts`
- `bun run test:config:src`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
<!-- SECTION:OUTCOME:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-204.1
title: Restore stale-only startup known-word cache refresh
status: Done
assignee:
- '@Codex'
created_date: '2026-03-20 02:52'
updated_date: '2026-03-20 03:02'
labels:
- anki
- cache
- bug
dependencies: []
references:
- src/anki-integration/known-word-cache.ts
- src/anki-integration/known-word-cache.test.ts
- docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md
parent_task_id: TASK-204
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Follow up on the incremental known-word cache change so startup still performs a refresh when the persisted cache is older than the configured refresh interval, while leaving fresh persisted state untouched.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Startup refreshes known words immediately when persisted cache state is stale for the configured interval.
- [x] #2 Startup skips the immediate refresh when persisted cache state is still fresh.
- [x] #3 Regression tests cover both stale and fresh startup paths.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused known-word cache lifecycle tests that distinguish fresh startup state from stale startup state and verify the stale path currently fails.
2. Update startup scheduling in src/anki-integration/known-word-cache.ts so persisted cache still loads immediately, but startup only triggers an immediate refresh when the cache is stale for the configured interval or the cache scope/config changed.
3. Run focused known-word cache tests and targeted SubMiner verification for the touched cache/runtime lane, then update the task with results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verified current lifecycle behavior: fresh persisted known-word cache already skips immediate startup refresh when the cache scope/config matches; stale persisted cache already refreshes immediately. Added regression coverage for both startup paths plus a proxy integration test showing addNote responses return without waiting for background enrichment.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added regression coverage for known-word cache startup behavior and proxy response timing. The cache tests now lock in the intended lifecycle: fresh persisted state stays load-only on startup, while stale persisted state refreshes immediately. Added a proxy integration test proving addNote responses return without waiting for background enrichment. Verification: targeted Bun tests passed (`bun test src/anki-connect.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/known-word-cache.test.ts src/anki-integration/note-update-workflow.test.ts src/anki-integration/runtime.test.ts`) and direct `bun run test:fast` passed. The `subminer-change-verification` helper repeatedly reported `bun run test:fast` as failed in its isolated lane despite the direct command passing, so that helper lane remains a flaky/blocking verification artifact rather than a reproduced code failure.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,62 @@
---
id: TASK-205
title: 'Address PR #19 Claude frontend review follow-ups'
status: Done
assignee:
- codex
created_date: '2026-03-20 02:41'
updated_date: '2026-03-20 02:46'
labels: []
milestone: m-1
dependencies: []
references:
- stats/src/components/vocabulary/VocabularyTab.tsx
- stats/src/hooks/useSessions.ts
- stats/src/hooks/useTrends.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Assess Claude's latest PR #19 review, apply any valid frontend fixes from that review batch, and verify the stats dashboard behavior stays unchanged aside from the targeted performance and error-handling improvements.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 VocabularyTab avoids recomputing expensive known-word and summary aggregates on unrelated rerenders while preserving current displayed values.
- [x] #2 useSessions and useSessionDetail normalize rejected values into stable string errors without throwing from the catch handler.
- [x] #3 Targeted tests cover the addressed review items and pass locally.
- [x] #4 Any user-facing docs remain accurate after the changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused tests that fail on the current branch for the two valid Claude findings: render-time aggregate recomputation in VocabularyTab and unsafe non-Error rejection handling in useSessions/useSessionDetail.
2. Update VocabularyTab to memoize the expensive summary and known-word aggregate calculations off the existing filteredWords/kanji/knownWords inputs without changing rendered values.
3. Normalize hook error handling to convert unknown rejection values into stable strings, matching the existing useTrends pattern.
4. Run the targeted stats/frontend test lane, verify no docs changes are needed, and record results in task notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Validated Claude's latest PR #19 review comment from 2026-03-20 and narrowed it to two valid frontend follow-ups: memoized VocabularyTab aggregates and non-Error-safe session hook error handling.
Added focused regression tests in stats/src/lib/vocabulary-tab.test.ts and stats/src/hooks/useSessions.test.ts before patching the implementation.
Verification: `cd stats && bun test src/lib/vocabulary-tab.test.ts src/hooks/useSessions.test.ts` passed; `bun run format:check:stats` passed.
Project-native verifier (`.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core ...`) passed root `bun run typecheck` and failed at `bun run test:fast` due an unrelated existing failure in `scripts/update-aur-package.test.ts` (`mapfile: command not found`). Artifact: `.tmp/skill-verification/subminer-verify-20260319-194525-vxVD9V`.
No user-facing docs changes were needed because the fixes only affect render-time memoization and error normalization.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed Claude's latest PR #19 review and applied the two valid follow-ups. `stats/src/components/vocabulary/VocabularyTab.tsx` now memoizes `buildVocabularySummary(filteredWords, kanji)` and the known-word count so unrelated rerenders do not rescan the filtered vocabulary list. `stats/src/hooks/useSessions.ts` now exports a small `toErrorMessage` helper and uses it in both `useSessions` and `useSessionDetail`, preventing `.catch()` handlers from throwing when a promise rejects with a non-`Error` value.
Added targeted regressions in `stats/src/lib/vocabulary-tab.test.ts` and `stats/src/hooks/useSessions.test.ts` to lock in the memoization shape and error normalization behavior. Verification passed for `cd stats && bun test src/lib/vocabulary-tab.test.ts src/hooks/useSessions.test.ts` and `bun run format:check:stats`. The repo-native verification wrapper for the classified `core` lane also passed root `bun run typecheck`, but `bun run test:fast` is currently blocked by an unrelated existing failure in `scripts/update-aur-package.test.ts` (`mapfile: command not found`); artifacts are recorded under `.tmp/skill-verification/subminer-verify-20260319-194525-vxVD9V`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,80 @@
---
id: TASK-206
title: 'Assess latest PR #19 CodeRabbit review comments'
status: Done
assignee:
- '@codex'
created_date: '2026-03-20 02:51'
updated_date: '2026-03-20 02:59'
labels:
- pr-review
- launcher
- anki-integration
- docs
milestone: m-1
dependencies: []
references:
- launcher/commands/command-modules.test.ts
- launcher/commands/stats-command.ts
- launcher/config/cli-parser-builder.ts
- launcher/mpv.ts
- README.md
- src/anki-integration.ts
- src/anki-integration/known-word-cache.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Validate the latest 2026-03-20 CodeRabbit review round on PR #19 against the current branch, implement only the confirmed fixes, and record which bot suggestions are stale, incorrect, or incomplete.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Each latest-round 2026-03-20 CodeRabbit inline comment on PR #19 is validated against current branch behavior and classified as actionable or not warranted
- [x] #2 Confirmed correctness issues in launcher, Anki integration, and docs are fixed with focused regression coverage where practical
- [x] #3 Targeted verification runs for the touched areas succeed or remaining unrelated failures are documented in task notes
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Pull the 2026-03-20 CodeRabbit review threads from PR #19 and validate each comment against the current branch, separating real issues from stale or incomplete bot guidance.
2. For each confirmed behavior bug, add or extend a focused failing test before changing production code; keep docs-only fixes scoped to the exact markdownlint/install issue.
3. Patch the smallest safe fixes in launcher, README, and Anki integration code, taking care not to overwrite unrelated local edits.
4. Run targeted tests and relevant SubMiner verification lanes for touched files, then record accepted versus rejected review comments in task notes and summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Validated the 2026-03-20 CodeRabbit PR #19 round as eight actionable items: one launcher test-name mismatch, three launcher behavior/test fixes, two README markdown/install fixes, one dead-code cleanup in Anki integration, and one real known-word cache deck-scoping bug.
Known-word cache review comment was correct in substance but needed a branch-specific fix: preserve deck->field scoping by querying per deck and carrying the allowed field list per note, rather than changing `notesInfo` shape.
Verification passed for targeted tests plus verifier docs/launcher-plugin lanes. Core verifier failed on unrelated pre-existing typecheck worktree state in `src/anki-integration/anki-connect-proxy.test.ts` (`TS2349` at line 395, `releaseProcessing?.()`), which is outside this task's touched files.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed the latest 2026-03-20 CodeRabbit review round on PR #19 and applied all eight confirmed action items. Launcher behavior now surfaces non-zero stats-process exits after the startup handshake, rejects cleanup-only stats flags unless `cleanup` is selected, preserves empty quoted `mpv` args, and has updated regression coverage for each case. The known-word cache now preserves deck-specific field mappings during refresh by querying configured decks separately and extracting only the fields assigned to each deck; the unused `getPreferredWordValue` wrapper in `src/anki-integration.ts` was removed.
Documentation/test hygiene fixes also landed: the README platform badge no longer has an empty link target, Linux AppImage install instructions create `~/.local/bin` before downloads, the stats-command timing test was renamed to match actual behavior, and `launcher/picker.test.ts` now restores `XDG_DATA_HOME` safely while forcing Linux-path expectations explicitly so the file passes on macOS hosts.
Verification run:
- `bun test launcher/commands/command-modules.test.ts`
- `bun test launcher/parse-args.test.ts`
- `bun test launcher/mpv.test.ts`
- `bun test launcher/picker.test.ts`
- `bun test src/anki-integration/known-word-cache.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh README.md launcher/commands/command-modules.test.ts launcher/commands/stats-command.ts launcher/config/cli-parser-builder.ts launcher/mpv.test.ts launcher/mpv.ts launcher/parse-args.test.ts launcher/picker.test.ts src/anki-integration.ts src/anki-integration/known-word-cache.test.ts src/anki-integration/known-word-cache.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane launcher-plugin --lane core README.md launcher/commands/command-modules.test.ts launcher/commands/stats-command.ts launcher/config/cli-parser-builder.ts launcher/mpv.test.ts launcher/mpv.ts launcher/parse-args.test.ts launcher/picker.test.ts src/anki-integration.ts src/anki-integration/known-word-cache.test.ts src/anki-integration/known-word-cache.ts`
Verifier results:
- `docs` lane passed (`docs:test`, `docs:build`)
- `launcher-plugin` lane passed (`test:launcher:smoke:src`, `test:plugin:src`)
- `core/typecheck` failed on unrelated existing worktree changes in `src/anki-integration/anki-connect-proxy.test.ts(395,5)`: `TS2349 This expression is not callable. Type 'never' has no call signatures.`
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260319-195752-RNLVgE`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,67 @@
---
id: TASK-207
title: 'Verify PR #19 follow-up typecheck blocker is cleared'
status: Done
assignee:
- '@codex'
created_date: '2026-03-20 03:03'
updated_date: '2026-03-20 03:04'
labels:
- pr-review
- anki-integration
- verification
milestone: m-1
dependencies: []
references:
- src/anki-integration/anki-connect-proxy.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Confirm the previously unrelated `anki-connect-proxy.test.ts` typecheck failure no longer blocks verification for the PR #19 CodeRabbit follow-up work, and only patch it if the failure still reproduces.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Reproduce or clear the `src/anki-integration/anki-connect-proxy.test.ts` typecheck blocker with current workspace state
- [x] #2 If the blocker still exists, apply the smallest safe fix and verify it
- [x] #3 Document the verification result and any remaining unrelated blockers
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Re-run `bun run typecheck` and a focused proxy test against the current workspace to confirm whether the previous `anki-connect-proxy.test.ts` failure still reproduces.
2. If the failure reproduces, use the typecheck failure itself as the red test, patch the smallest type-safe fix in the test, and rerun focused verification.
3. Re-run the relevant verifier lane(s), then record whether the blocker is cleared or if any unrelated failures remain.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Re-ran `bun run typecheck` against the current workspace and the prior `src/anki-integration/anki-connect-proxy.test.ts` blocker no longer reproduces.
Focused verification passed for `bun test src/anki-integration/anki-connect-proxy.test.ts`. Core verifier now passes `typecheck` and reaches `test:fast`.
Current remaining unrelated verifier failure is unchanged local environment behavior in `scripts/update-aur-package.test.ts`: `scripts/update-aur-package.sh: line 71: mapfile: command not found` under macOS Bash. Artifact: `.tmp/skill-verification/subminer-verify-20260319-200320-vy2YHa`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Verified the previously reported PR #19 follow-up typecheck blocker is cleared in the current workspace. `bun run typecheck` now passes, and the focused proxy regression file `src/anki-integration/anki-connect-proxy.test.ts` also passes, including the background-enrichment response timing test.
Re-running the SubMiner core verifier confirms the blocker moved forward: `core/typecheck` passes, and the remaining `core/test-fast` failure is unrelated to the proxy test. The only red is the existing macOS Bash compatibility issue in `scripts/update-aur-package.test.ts`, where `scripts/update-aur-package.sh` uses `mapfile` and exits with `line 71: mapfile: command not found`.
Verification run:
- `bun run typecheck`
- `bun test src/anki-integration/anki-connect-proxy.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/anki-integration/anki-connect-proxy.test.ts`
Verifier result:
- `core/typecheck` passed
- `core/test-fast` failed only in `scripts/update-aur-package.test.ts` because local macOS Bash lacks `mapfile`
- Artifact: `.tmp/skill-verification/subminer-verify-20260319-200320-vy2YHa`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,72 @@
---
id: TASK-208
title: 'Assess newest PR #19 CodeRabbit round after 1227706'
status: Done
assignee:
- '@codex'
created_date: '2026-03-20 03:37'
updated_date: '2026-03-20 03:47'
labels:
- pr-review
- launcher
- anki-integration
milestone: m-1
dependencies: []
references:
- launcher/commands/stats-command.ts
- launcher/mpv.ts
- src/anki-integration.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Validate the newest 2026-03-20 03:23 CodeRabbit review round on PR #19 after commit `1227706`, implement only the confirmed fixes, and record any bot suggestions that are stale or technically incomplete.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Each newest-round CodeRabbit inline comment posted after commit `1227706` is validated against current branch behavior and classified as actionable or not warranted
- [x] #2 Confirmed issues are fixed with focused regression coverage where practical
- [x] #3 Targeted verification runs for the touched areas succeed or remaining unrelated failures are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Pull the three newest CodeRabbit inline threads posted after commit `1227706` and restate each finding against the current branch code.
2. For each confirmed behavior bug, add or extend a focused failing test before changing production code; reject any stale or incorrect bot suggestion with notes.
3. Patch the smallest safe fixes in `launcher/commands/stats-command.ts`, `launcher/mpv.ts`, and/or `src/anki-integration.ts` as warranted, without disturbing unrelated local edits.
4. Run targeted tests and the cheapest sufficient verifier lanes, then record accepted versus rejected comments in task notes and summary.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Validated the newest 2026-03-20 03:23 CodeRabbit round as three comments: two actionable launcher issues and one non-warranted Anki suggestion.
Accepted fixes: cancel the pending stats response poll when the attached app exits non-zero before startup response, and surface `spawnSync()` launch/stop errors in launcher mpv helpers instead of treating `result.status ?? 0` / ignored status as success.
Rejected fix: the `src/anki-integration.ts` / card-creation suggestion would double count locally mined cards. Local sentence mining already records stats in `src/main/runtime/anki-actions.ts` when `mineSentenceCardCore` returns `true`; adding a second callback in card creation would increment tracker counts twice for the same card.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed the newest CodeRabbit PR #19 round after commit `1227706` and fixed the two confirmed launcher regressions. `runStatsCommand()` now gives the startup response waiter an abort signal and cancels the polling loop immediately when the attached app exits non-zero before startup response, covering both the normal stats startup race and the cleanup/startup race. `launchTexthookerOnly()` now fails non-zero when `spawnSync()` reports an execution error, and `stopOverlay()` logs a warning when the stop command cannot be spawned or exits non-zero instead of silently treating that path as success.
One bot comment was intentionally rejected: recording mined-card stats inside the direct card-creation path would double count locally mined cards, because the successful local mining flow already records cards in `src/main/runtime/anki-actions.ts` after `mineSentenceCardCore()` returns `true`.
Verification run:
- `bun test launcher/commands/command-modules.test.ts`
- `bun test launcher/mpv.test.ts`
- `bun run typecheck`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh launcher/commands/stats-command.ts launcher/commands/command-modules.test.ts launcher/mpv.ts launcher/mpv.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane launcher-plugin launcher/commands/stats-command.ts launcher/commands/command-modules.test.ts launcher/mpv.ts launcher/mpv.test.ts`
Verifier result:
- `launcher-plugin` lane passed (`test:launcher:smoke:src`, `test:plugin:src`)
- `typecheck` passed
- Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260319-204639-dzUj16`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-209
title: Exclude grammar-tail そうだ from subtitle annotations
status: Done
assignee:
- codex
created_date: '2026-03-20 04:06'
updated_date: '2026-03-20 04:33'
labels:
- bug
- tokenizer
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer/annotation-stage.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer/annotation-stage.test.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.test.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Sentence-final grammar-tail `そうだ` tokens can still receive subtitle annotation styling, including frequency highlighting, when Yomitan returns a standalone `そうだ` token and MeCab enriches it as an auxiliary-stem/coupla pattern (`名詞|助動詞`, `助動詞語幹`). Keep the subtitle text visible, but treat this grammar tail like other grammar-only endings so it renders without annotation metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Sentence-final grammar-tail `そうだ` tokens enriched as auxiliary-stem/copula patterns do not receive frequency highlighting or other subtitle annotation metadata.
- [x] #2 The preceding lexical token in cases like `与えるそうだ` keeps its existing annotation behavior.
- [x] #3 Regression tests cover the annotation-stage exclusion and end-to-end subtitle tokenization for the `そうだ` grammar-tail case.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused regression coverage for the reported `与えるそうだ` case at both annotation-stage and tokenizeSubtitle levels.
2. Reproduce failure by modeling the MeCab-enriched grammar-tail shape (`名詞|助動詞`, `特殊`, `助動詞語幹`) that currently keeps frequency metadata.
3. Update subtitle-annotation exclusion logic to recognize auxiliary-stem/copula grammar tails via POS metadata plus normalized tail text, not a raw sentence-specific string match.
4. Re-run targeted tokenizer and annotation-stage tests, then record the verification commands and outcome in the task notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigated reported `与えるそうだ` case. MeCab tags `そう` as `名詞,特殊,助動詞語幹` and `だ` as `助動詞`; after overlap enrichment the Yomitan token becomes `pos1=名詞|助動詞`, `pos2=特殊`, `pos3=助動詞語幹`, which currently escapes subtitle-annotation exclusion and can keep a frequency rank.
Implemented a POS-shape subtitle-annotation exclusion for MeCab-enriched auxiliary-stem grammar tails. The new predicate keys off merged tokens whose POS tags stay within `名詞/助動詞/助詞` and whose POS3 includes `助動詞語幹`, which clears annotation metadata for `そうだ`-style tails without hard-coding the full subtitle text.
Verification: `bun test src/core/services/tokenizer/annotation-stage.test.ts`, `bun test src/core/services/tokenizer.test.ts --test-name-pattern 'explanatory ending|interjection|single-kana merged tokens from frequency highlighting|auxiliary-stem そうだ grammar tails|composite function/content token from frequency highlighting|keeps frequency for content-led merged token with trailing colloquial suffixes'`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added regression coverage for `与えるそうだ` and updated subtitle annotation exclusion logic to drop annotation metadata for MeCab-enriched auxiliary-stem grammar tails. The fix is POS-driven rather than sentence-specific, so `そうだ`-style grammar endings stay visible/hoverable as plain text while neighboring lexical tokens keep their existing frequency/JLPT behavior.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,62 @@
---
id: TASK-210
title: Show latest session position in anime episode progress
status: Done
assignee:
- '@Codex'
created_date: '2026-03-20 04:09'
updated_date: '2026-03-20 04:25'
labels:
- stats
- bug
- ui
milestone: m-1
dependencies: []
references:
- stats/src/components/anime/EpisodeList.tsx
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker/session.ts
- src/core/services/immersion-tracker-service.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Anime episode rows in stats can show watch time and lookups from the latest session while the Progress column stays blank because it only reads `ended_media_ms` from ended sessions. Update the progress source so a just-watched episode reflects the latest known session stop position without falling back to cumulative watch time.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Anime episode progress uses the latest known session position for the episode, including the most recent active session when available.
- [x] #2 Ended-session progress remains correct and does not regress to cumulative watch time.
- [x] #3 Regression coverage locks query and/or UI behavior for active-session and ended-session episode progress.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression coverage for anime episode progress when the latest session is still active but has a known playback position.
2. Persist the latest playback position on the active `imm_sessions` row during playback so stats queries can read it before session finalization.
3. Update anime episode queries to use the newest known session position for progress while preserving ended-session behavior.
4. Run targeted verification for immersion tracker, stats query, and cheap repo checks; record results and task outcome.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: stale active-session recovery rebuilt session state with `lastMediaMs = null`, so `finalizeSessionRecord` overwrote persisted progress checkpoints with `ended_media_ms = NULL` during startup reconciliation.
Implemented telemetry-flush checkpointing to persist `lastMediaMs` onto the active `imm_sessions` row, preserved that checkpoint through stale-session reconciliation, and updated anime episode progress queries to read the latest known non-null session position across active or ended sessions.
Verification: targeted regressions passed (`bun test src/core/services/immersion-tracker-service.test.ts --test-name-pattern 'flushTelemetry checkpoints latest playback position on the active session row|startup finalizes stale active sessions and applies lifetime summaries'`, `bun test src/core/services/immersion-tracker/__tests__/query.test.ts --test-name-pattern 'getAnimeEpisodes prefers the latest session media position when the latest session is still active|getAnimeEpisodes returns latest ended media position and aggregate metrics'`), broader tracker/query suite passed (`bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/__tests__/query.test.ts`), `bun run typecheck` passed via verifier, `bun run changelog:lint` passed.
Verification blocker: `.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core ...` reported `bun run test:fast` failure from pre-existing `scripts/update-aur-package.test.ts` (`mapfile: command not found` under bash), unrelated to this change set.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Persist anime episode progress checkpoints before session finalization so stats can survive crashes/restarts and still show the latest known watch position. Telemetry flushes now checkpoint `lastMediaMs` onto the active `imm_sessions` row, stale-session recovery preserves that checkpoint when finalizing recovered sessions, and `getAnimeEpisodes` now reads the newest non-null session position whether it came from an active or ended session.
Added regressions for active-session checkpoint persistence, stale-session recovery preserving `ended_media_ms`, and episode queries preferring the latest known session position. Verification passed for the targeted and broader immersion tracker/query suites, plus `bun run typecheck` and `bun run changelog:lint`. The verifier's `bun run test:fast` step still fails on the pre-existing `scripts/update-aur-package.test.ts` bash `mapfile` issue, which is outside this task's scope.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,33 @@
---
id: TASK-211
title: Recover anime episode progress from subtitle timing when checkpoints are missing
status: Done
assignee:
- '@Codex'
created_date: '2026-03-20 10:15'
updated_date: '2026-03-20 10:22'
labels:
- stats
- bug
milestone: m-1
dependencies: []
references:
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker/__tests__/query.test.ts
---
## Description
Anime episode progress can still show `0%` for older sessions that have watch-time and subtitle timing but no persisted `ended_media_ms` checkpoint. Recover progress from the latest retained subtitle/event segment end so already-recorded sessions render a useful progress percentage.
## Acceptance Criteria
- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists.
- [x] Existing ended-session metrics and aggregation totals do not regress.
- [x] Regression coverage locks the fallback behavior.
## Implementation Notes
Added a query-side fallback for anime episode progress: when the newest session for a video has no persisted `ended_media_ms`, `getAnimeEpisodes` now uses the latest retained subtitle-line or session-event `segment_end_ms` from that same session. This recovers useful progress for already-recorded sessions that have timing data but predate or missed checkpoint persistence.
Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed.

View File

@@ -0,0 +1,43 @@
---
id: TASK-212
title: Fix mac texthooker helper startup blocking mpv launch
status: In Progress
assignee: []
created_date: '2026-03-20 08:27'
updated_date: '2026-03-20 08:45'
labels:
- bug
- macos
- startup
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/startup.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`subminer` mpv auto-start on mac can stall before the video is usable because the helper process launched with `--texthooker` still runs heavy app-ready startup. Recent logs show the helper loading the Yomitan Chromium extension, emitting `Permission 'contextMenus' is unknown` warnings, then hitting Chromium runtime errors before SubMiner signals readiness back to the mpv plugin. The texthooker helper should take the minimal startup path needed to serve texthooker traffic without loading overlay/window-only startup work that can crash or delay readiness.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launching SubMiner with `--texthooker` avoids heavy app-ready startup work that is not required for texthooker helper mode.
- [x] #2 A regression test covers texthooker helper startup so it fails if Yomitan extension loading is reintroduced on that path.
- [x] #3 The change preserves existing startup behavior for non-texthooker app launches.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Follow-up: user confirmed the root issue is the plugin auto-start ordering. Adjust mpv plugin sequencing so `--start` launches before any separate `--texthooker` helper, then verify plugin regressions still pass.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the mac mpv startup hang caused by the `--texthooker` helper taking the full app-ready path. `runAppReadyRuntime` now fast-paths texthooker-only mode through minimal startup (`reloadConfig` plus CLI handling) so it no longer loads Yomitan or first-run setup work before serving texthooker traffic. Added regression coverage in `src/core/services/app-ready.test.ts`, then verified with `bun test src/core/services/app-ready.test.ts src/core/services/startup.test.ts`, `bun test src/cli/args.test.ts src/main/early-single-instance.test.ts src/main/runtime/stats-cli-command.test.ts`, and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,42 @@
---
id: TASK-213
title: Show character dictionary progress during paused startup waits
status: In Progress
assignee: []
created_date: '2026-03-20 08:59'
updated_date: '2026-03-20 09:22'
labels:
- bug
- ux
- dictionary
- startup
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
During startup on mpv auto-start, character dictionary regeneration/update can be active while playback remains paused. The current startup OSD sequencer buffers dictionary progress behind annotation-loading OSD, which leaves the user with no visible dictionary-specific progress while the pause is active. Adjust the startup OSD sequencing so dictionary progress can surface once tokenization is ready during the paused startup window, without regressing later ready/failure handling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 When tokenization is ready during startup, later character dictionary progress updates are shown on OSD even if annotation-loading state is still active.
- [ ] #2 Startup OSD completion/failure behavior for character dictionary sync remains coherent after the new progress ordering.
- [ ] #3 Regression coverage exercises the paused startup sequencing for dictionary progress.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-20: Confirmed issue is broader than OSD-only. Paused-startup OSD fixes remain relevant, but current user report also points at a regression in non-blocking startup playback release (tracked in TASK-143).
2026-03-20: OSD sequencing fix remains in local patch alongside TASK-143 regression fix. Covered by startup-osd-sequencer tests; pending installed-app/mpv validation before task finalization.
<!-- SECTION:NOTES:END -->

View File

@@ -1,5 +0,0 @@
type: changed
area: anki
- Changed known-word cache settings to live under `ankiConnect.knownWords` instead of mixing them into `ankiConnect.nPlusOne`.
- Kept legacy `ankiConnect.nPlusOne` known-word keys and older `ankiConnect.behavior.nPlusOne*` keys as deprecated compatibility fallbacks.

View File

@@ -1,4 +0,0 @@
type: fixed
area: launcher
- Fixed mpv Lua plugin binary auto-detection on Linux to also search `/usr/bin/subminer` and `/usr/local/bin/subminer` (lowercase), matching the conventional Unix wrapper name used by packaged installs such as the AUR package.

View File

@@ -1,4 +0,0 @@
type: changed
area: stats
- Added session deletion to the Sessions tab with the same confirmation prompt used by anime episode/session deletes, and removed all associated session rows from the stats database.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Fixed the in-app stats overlay so it connects to the configured `stats.serverPort` instead of falling back to the default port.

View File

@@ -1,9 +0,0 @@
type: fixed
area: overlay
- Fixed subtitle frequency tagging for merged lookup-backed tokens like `陰に` by falling back to exact surface-form Yomitan frequencies when the normalized headword lookup misses.
- Fixed MeCab merged-token position mapping across line breaks so merged content-plus-particle tokens like `陰に` keep their matched Yomitan frequency instead of inheriting shifted POS tags.
- Fixed grouped frequency parsing in both Yomitan and fallback frequency-dictionary lookups so display values like `118,121` use the leading rank instead of collapsing the rank and occurrence count into `118121`.
- Fixed frequency-rank ingestion to ignore Yomitan dictionaries explicitly marked `occurrence-based`, so raw occurrence counts are no longer treated as subtitle rank values.
- Fixed inflected headword frequency tagging to prefer ranks from the selected Yomitan `termsFind` popup entry itself, ordered by configured dictionary priority, so forms like `潜み` use primary-dictionary ranks like `4073` before falling back to lower-priority raw lemma metadata such as `CC100`.
- Fixed annotation-stage frequency filtering so exact kanji noun tokens like `者` keep their matched rank even when MeCab labels them `名詞/非自立`, instead of dropping the highlight after scan-time frequency lookup succeeds.

View File

@@ -1,4 +0,0 @@
type: fixed
area: anki
- Fixed repeated character-dictionary startup work by scheduling auto-sync only from mpv media-path changes instead of also re-triggering it from connection and media-title events for the same title.

View File

@@ -1,7 +0,0 @@
type: fixed
area: overlay
- Fixed macOS fullscreen overlay stability by keeping the passive visible overlay from stealing focus, re-raising the overlay window when reasserting its macOS topmost level, and tolerating one transient macOS tracker/helper miss before hiding the overlay.
- Kept subtitle tokenization warmup one-shot for the lifetime of the app so later fullscreen/media churn on macOS does not replay the startup warmup gate after the first file is ready.
- Added a bounded macOS tracker loss-grace window so fullscreen enter/leave transitions do not immediately hide and reload the overlay when the helper briefly loses the mpv window.
- Skipped subtitle/tokenization refresh invalidation on character-dictionary auto-sync completion when the dictionary was already current, preventing startup flash/reload loops on unchanged media.

View File

@@ -1,11 +0,0 @@
type: added
area: immersion
- Added Mine Word, Mine Sentence, and Mine Audio buttons to word detail example lines in the stats dashboard.
- Mine Word creates a full Yomitan card (definition, reading, pitch accent) via the hidden search page bridge, then enriches with sentence audio, screenshot, and metadata extracted from the source video.
- Mine Sentence and Mine Audio create cards directly with appropriate Lapis/Kiku flags, sentence highlighting, and media from the source file.
- Media generation (audio + image/AVIF) runs in parallel and respects all AnkiConnect config options.
- Added word exclusion list to the Vocabulary tab with localStorage persistence and a management modal.
- Fixed truncated readings in the frequency rank table (e.g. お前 now shows おまえ instead of まえ).
- Clicking a bar in the Top Repeated Words chart now opens the word detail panel.
- Secondary subtitle text is now stored alongside primary subtitle lines for use as translation when mining cards from the stats page.

View File

@@ -1,8 +0,0 @@
type: changed
area: immersion
- Kept immersion tracking history by default while preserving daily/monthly rollup maintenance.
- Added exact lifetime summary reads for overview/anime/media stats so dashboard totals no longer depend on rescanning raw telemetry.
- Reduced tracker storage overhead by removing duplicated subtitle text from subtitle-line event payloads.
- Deduplicated episode cover-art blobs through a shared blob store and updated cover-art reads/writes to resolve shared images correctly.
- Added indexes for large-history session, telemetry, vocabulary, kanji, and cover-art queries to keep dashboard reads fast as the SQLite database grows.

View File

@@ -1,6 +0,0 @@
type: added
area: stats
- Added `subminer stats -b` to start or reuse a dedicated background stats server without blocking normal SubMiner instances.
- Added `subminer stats -s` to stop the dedicated background stats server without closing browser tabs.
- Stats server startup now reuses a running background stats daemon instead of trying to bind a second local server in another SubMiner instance.

View File

@@ -1,5 +0,0 @@
type: fixed
area: stats
- Fixed session stats so known-word counts track real known-word occurrences without collapsing subtitle-line gaps.
- Fixed session word totals in session-facing stats views to prefer token counts when available, preventing known words from exceeding total words in the session chart.

View File

@@ -1,4 +0,0 @@
type: changed
area: immersion
- Renamed the stats dashboard's Anime tab to Library so the media browser label matches non-anime sources like YouTube and other yt-dlp-backed content.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Fixed the stats Vocabulary tab blank-screen regression caused by a hook-order crash after vocabulary data finished loading.

View File

@@ -1,5 +0,0 @@
type: changed
area: anilist
- Standardized episode completion threshold by introducing `DEFAULT_MIN_WATCH_RATIO` and using it for both local watched state transitions and AniList post-watch progress updates.
- Episode auto-marking now uses the same threshold as AniList (`85%`), removing divergent completion behavior.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Removed the misleading `New words` series from expanded session charts; session detail now shows only the real total-word and known-word lines.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Restored the cross-anime word table behavior in stats vocabulary surfaces so shared vocabulary entries no longer disappear or merge incorrectly across related media.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Load full session timelines by default in stats session detail views so long sessions preserve complete telemetry history instead of being truncated by a fixed sample limit.

View File

@@ -1,4 +0,0 @@
type: added
area: launcher
- Added launcher passthrough for `-a/--args` so mpv receives raw extra launch flags (`--fs`, `--ytdl-format`, custom audio/video settings, etc.) from the `subminer` command.

View File

@@ -1,5 +0,0 @@
type: fixed
area: stats
- Replaced heuristic stats word counts with Yomitan token counts, so session, media, anime, and trend subtitle totals now come directly from parsed subtitle tokens.
- Updated stats UI labels and lookup-rate copy to refer to tokens instead of words where those counts are shown.

View File

@@ -1,4 +0,0 @@
type: changed
area: overlay
- Excluded interjections and sound-effect tokens from subtitle annotation styling so they no longer inherit misleading lexical highlight treatment while still remaining visible and non-interactive in the subtitle line.

View File

@@ -0,0 +1,4 @@
type: fixed
area: anki
- Known-word cache refreshes now reconcile Anki changes incrementally instead of wiping and rebuilding on startup, mined cards can append their word into the cache immediately through a new default-enabled config flag, and explicit refreshes now run through `subminer doctor --refresh-known-words`.

View File

@@ -0,0 +1,4 @@
type: fixed
area: subtitle
- Restored known-word coloring and JLPT underlines for subtitle tokens like `大体` when the subtitle token is kanji but the known-word cache only matches the kana reading.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Episode progress in the anime page now uses the last ended playback position instead of cumulative active watch time, avoiding distorted percentages after rewatches or repeated sessions.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Anime episode progress now keeps the latest known playback position through active-session checkpoints and stale-session recovery, so recently watched episodes no longer lose their progress percentage.

View File

@@ -0,0 +1,4 @@
type: changed
area: docs
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.

View File

@@ -0,0 +1,4 @@
type: fixed
area: stats
- Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress.

View File

@@ -1,4 +0,0 @@
type: fixed
area: jlpt
- Reduced JLPT dictionary startup log noise by summarizing duplicate surface-form collisions instead of logging one line per duplicate entry.

View File

@@ -1,6 +0,0 @@
type: added
area: launcher
- Added `subminer stats` to launch the local stats dashboard, force-start the stats server on demand, and open the dashboard in your browser.
- Added `subminer stats cleanup` to backfill vocabulary metadata and prune stale or excluded immersion rows on demand.
- Added `stats.autoOpenBrowser` so browser launch after `subminer stats` can be enabled or disabled explicitly.

View File

@@ -1,7 +0,0 @@
type: added
area: immersion
- Added a local stats dashboard for immersion tracking with Overview, Anime, Trends, Vocabulary, and Sessions views.
- Added anime progress, episode completion, Anki card links, and occurrence drill-down across the stats dashboard.
- Added richer session timelines with new-word activity, cumulative totals, and pause/seek/card event markers.
- Added completed-episodes and completed-anime totals to the Overview tracking snapshot.

View File

@@ -319,7 +319,7 @@
"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.
"fields": { "fields": {
"word": "Expression", // Word setting. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.
@@ -340,6 +340,7 @@
"animatedFps": 10, // Animated fps setting. "animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, // Animated max width setting. "animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, // Animated crf setting. "animatedCrf": 35, // Animated crf setting.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0.5, // Audio padding setting. "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
@@ -347,6 +348,7 @@
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights. "color": "#a6da95" // Color used for known-word highlights.

View File

@@ -1,5 +1,14 @@
# Changelog # Changelog
## v0.7.0 (2026-03-19)
- Added a full local immersion dashboard release line with Overview, Library, Trends, Vocabulary, and Sessions drill-down views backed by SQLite tracking data.
- Added browser-first stats workflows: `subminer stats`, background stats daemon controls (`-b` / `-s`), stats cleanup, and dashboard-side mining actions with media enrichment.
- Improved stats accuracy and scale handling with Yomitan token counts, full session timelines, known-word timeline fixes, cross-media vocabulary fixes, and clearer session charts.
- Improved overlay/runtime stability with quieter macOS fullscreen recovery, reduced repeated loading OSD popups, and better frequency/noise handling for subtitle annotations.
- Added launcher mpv-args passthrough plus Linux plugin wrapper-name fallback for packaged installs.
- Added a hover-revealed ↗ button on Sessions tab rows to navigate directly to the anime media-detail view, with correct "Back to Sessions" back-navigation.
- Excluded auxiliary-stem `そうだ` grammar tails (MeCab POS3 `助動詞語幹`) from subtitle annotation metadata so frequency, JLPT, and N+1 styling no longer bleed onto grammar-tail tokens.
## v0.6.5 (2026-03-15) ## v0.6.5 (2026-03-15)
- Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state. - Seeded the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.

View File

@@ -26,7 +26,7 @@ The same immersion data powers the stats dashboard.
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`). - In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser. - Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser.
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats server without keeping the launcher attached, and `subminer stats -s` to stop that background server. - Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand. - Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand.
- Browser page: open `http://127.0.0.1:5175` directly if the local stats server is already running. - Browser page: open `http://127.0.0.1:5175` directly if the local stats server is already running.
@@ -52,7 +52,7 @@ Watch time, sessions, words seen, and per-anime progress/pattern charts with con
#### Sessions #### Sessions
Expandable session history with new-word activity, cumulative totals, and pause/seek/card markers. Expandable session history with new-word activity, cumulative totals, and pause/seek/card markers. Each session row exposes a hover-revealed ↗ button that navigates to the anime media-detail view for that session; pressing the back button there returns to the Sessions tab.
![Stats Sessions](/screenshots/stats-sessions.png) ![Stats Sessions](/screenshots/stats-sessions.png)
@@ -80,8 +80,9 @@ Stats server config lives under `stats`:
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. - `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running. - `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`. - `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
- `subminer stats -b` starts or reuses the dedicated background stats server and exits after startup acknowledgement. - `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
- `subminer stats -s` stops the dedicated background stats server without closing any browser tabs. - The background stats daemon is separate from the normal SubMiner overlay app, so you can leave it running and still launch SubMiner later to watch or mine from video.
- `subminer stats -s` stops the dedicated background stats daemon without closing any browser tabs.
- `subminer stats` fails with an error when `immersionTracking.enabled` is `false`. - `subminer stats` fails with an error when `immersionTracking.enabled` is `false`.
- `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering. - `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering.
@@ -89,7 +90,7 @@ Stats server config lives under `stats`:
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 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:
- **Mine Word** — performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via the hidden search page, 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, image, and translation from the secondary subtitle if available.
- **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.

View File

@@ -319,7 +319,7 @@
"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.
"fields": { "fields": {
"word": "Expression", // Word setting. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting. "audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting. "image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting. "sentence": "Sentence", // Sentence setting.
@@ -340,6 +340,7 @@
"animatedFps": 10, // Animated fps setting. "animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, // Animated max width setting. "animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, // Animated crf setting. "animatedCrf": 35, // Animated crf setting.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0.5, // Audio padding setting. "audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting. "fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting. "maxMediaDuration": 30 // Max media duration setting.
@@ -347,6 +348,7 @@
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"refreshMinutes": 1440, // Minutes between known-word cache refreshes. "refreshMinutes": 1440, // Minutes between known-word cache refreshes.
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface "matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }. "decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
"color": "#a6da95" // Color used for known-word highlights. "color": "#a6da95" // Color used for known-word highlights.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -4,6 +4,8 @@ SubMiner annotates subtitle tokens in real time as they appear in the overlay. F
All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination. All four are opt-in and configured under `subtitleStyle`, `ankiConnect.knownWords`, and `ankiConnect.nPlusOne` in your config. They apply independently — you can enable any combination.
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
## N+1 Word Highlighting ## N+1 Word Highlighting
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly. N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
@@ -80,6 +82,10 @@ When `sourcePath` is omitted, SubMiner searches default install/runtime location
Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist. Frequency highlighting skips tokens that look like non-lexical noise (kana reduplication, short kana endings like `っ`), even when dictionary ranks exist.
::: :::
::: info
Frequency, JLPT, and N+1 metadata are only shown for tokens that survive the subtitle-annotation noise filter. Standalone grammar tokens like `は`, `です`, and `この` are intentionally left unannotated even if a dictionary can assign them metadata.
:::
## JLPT Tagging ## JLPT Tagging
JLPT tagging adds colored underlines to tokens based on their JLPT level (N1N5), giving you an at-a-glance sense of difficulty distribution in each subtitle line. JLPT tagging adds colored underlines to tokens based on their JLPT level (N1N5), giving you an at-a-glance sense of difficulty distribution in each subtitle line.

View File

@@ -7,7 +7,7 @@
3. Run `bun run changelog:lint`. 3. Run `bun run changelog:lint`.
4. Bump `package.json` to the release version. 4. Bump `package.json` to the release version.
5. Build release metadata before tagging: 5. Build release metadata before tagging:
`bun run changelog:build --version <version>` `bun run changelog:build --version <version> --date <yyyy-mm-dd>`
6. Review `CHANGELOG.md` and `release/release-notes.md`. 6. Review `CHANGELOG.md` and `release/release-notes.md`.
7. Run release gate locally: 7. Run release gate locally:
`bun run changelog:check --version <version>` `bun run changelog:check --version <version>`
@@ -25,6 +25,8 @@
Notes: Notes:
- Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.

File diff suppressed because it is too large Load Diff

View File

@@ -145,19 +145,25 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
}); });
test('buildSubminerScriptOpts includes aniskip payload fields', () => { test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', { const opts = buildSubminerScriptOpts(
title: "Frieren: Beyond Journey's End", '/tmp/SubMiner.AppImage',
season: 1, '/tmp/subminer.sock',
episode: 5, {
source: 'guessit', title: "Frieren: Beyond Journey's End",
malId: 1234, season: 1,
introStart: 30.5, episode: 5,
introEnd: 62, source: 'guessit',
lookupStatus: 'ready', malId: 1234,
}); introStart: 30.5,
introEnd: 62,
lookupStatus: 'ready',
},
'debug',
);
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-log_level=debug/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/); assert.match(opts, /subminer-aniskip_episode=5/);

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import type { LogLevel } from './types.js';
import { commandExists } from './util.js'; import { commandExists } from './util.js';
export type AniSkipLookupStatus = export type AniSkipLookupStatus =
@@ -551,11 +552,15 @@ export function buildSubminerScriptOpts(
appPath: string, appPath: string,
socketPath: string, socketPath: string,
aniSkipMetadata: AniSkipMetadata | null, aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info',
): string { ): string {
const parts = [ const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
]; ];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
}
if (aniSkipMetadata && aniSkipMetadata.title) { if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
} }

View File

@@ -48,6 +48,64 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
}; };
} }
type StatsTestArgOverrides = {
stats?: boolean;
statsBackground?: boolean;
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean;
statsStop?: boolean;
logLevel?: LauncherCommandContext['args']['logLevel'];
};
function createStatsTestHarness(overrides: StatsTestArgOverrides = {}) {
const context = createContext();
const forwarded: string[][] = [];
const removedPaths: string[] = [];
const createTempDir = (_prefix: string) => {
const created = `/tmp/subminer-stats-test`;
return created;
};
const joinPath = (...parts: string[]) => parts.join('/');
const removeDir = (targetPath: string) => {
removedPaths.push(targetPath);
};
const runAppCommandAttachedStub = async (
_appPath: string,
appArgs: string[],
_logLevel: LauncherCommandContext['args']['logLevel'],
_label: string,
) => {
forwarded.push(appArgs);
return 0;
};
const waitForStatsResponseStub = async () => ({ ok: true, url: 'http://127.0.0.1:5175' });
context.args = {
...context.args,
stats: true,
...overrides,
};
return {
context,
forwarded,
removedPaths,
createTempDir,
joinPath,
removeDir,
runAppCommandAttachedStub,
waitForStatsResponseStub,
commandDeps: {
createTempDir,
joinPath,
runAppCommandAttached: runAppCommandAttachedStub,
waitForStatsResponse: waitForStatsResponseStub,
removeDir,
},
};
}
test('config command writes newline-terminated path via process adapter', () => { test('config command writes newline-terminated path via process adapter', () => {
const writes: string[] = []; const writes: string[] = [];
const context = createContext(); const context = createContext();
@@ -77,11 +135,37 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
commandExists: () => false, commandExists: () => false,
configExists: () => true, configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: () => {
throw new Error('unexpected app handoff');
},
}), }),
(error: unknown) => error instanceof ExitSignal && error.code === 1, (error: unknown) => error instanceof ExitSignal && error.code === 1,
); );
}); });
test('doctor command forwards refresh-known-words to app binary', () => {
const context = createContext();
context.args.doctor = true;
context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = [];
assert.throws(
() =>
runDoctorCommand(context, {
commandExists: () => false,
configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
throw new ExitSignal(0);
},
}),
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('mpv pre-app command exits non-zero when socket is not ready', async () => { test('mpv pre-app command exits non-zero when socket is not ready', async () => {
const context = createContext(); const context = createContext();
context.args.mpvStatus = true; context.args.mpvStatus = true;
@@ -131,24 +215,11 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
}); });
test('stats command launches attached app command with response path', async () => { test('stats command launches attached app command with response path', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
context.args.stats = true; const handled = await runStatsCommand(harness.context, harness.commandDeps);
context.args.logLevel = 'debug';
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -157,53 +228,34 @@ test('stats command launches attached app command with response path', async ()
'debug', 'debug',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats background command launches detached app command with response path', async () => { test('stats background command launches attached daemon control command with response path', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true, statsBackground: true });
context.args.stats = true; const handled = await runStatsCommand(harness.context, harness.commandDeps);
(context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async () => {
throw new Error('attached path should not run for stats -b');
},
launchAppCommandDetached: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
} as Parameters<typeof runStatsCommand>[1]);
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
[ [
'--stats', '--stats-daemon-start',
'--stats-response-path', '--stats-response-path',
'/tmp/subminer-stats-test/response.json', '/tmp/subminer-stats-test/response.json',
'--stats-background',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command returns after startup response even if app process stays running', async () => { test('stats command waits for attached app exit after startup response', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true });
context.args.stats = true;
const forwarded: string[][] = [];
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20)); const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
const statsCommand = runStatsCommand(context, { const statsCommand = runStatsCommand(harness.context, {
createTempDir: () => '/tmp/subminer-stats-test', ...harness.commandDeps,
joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async (...args) => {
runAppCommandAttached: async (_appPath, appArgs) => { await harness.runAppCommandAttachedStub(...args);
forwarded.push(appArgs);
return started; return started;
}, },
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
}); });
const result = await Promise.race([ const result = await Promise.race([
statsCommand.then(() => 'resolved'), statsCommand.then(() => 'resolved'),
@@ -214,31 +266,46 @@ test('stats command returns after startup response even if app process stays run
const final = await statsCommand; const final = await statsCommand;
assert.equal(final, true); assert.equal(final, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json'], [
'--stats',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
]); ]);
assert.equal(harness.removedPaths.length, 1);
});
test('stats command throws when attached app exits non-zero after startup response', async () => {
const harness = createStatsTestHarness({ stats: true });
await assert.rejects(async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
await new Promise((resolve) => setTimeout(resolve, 10));
return 3;
},
});
}, /Stats app exited with status 3\./);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command forwards cleanup vocab flags to the app', async () => { test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const context = createContext(); const harness = createStatsTestHarness({
context.args.stats = true; stats: true,
context.args.statsCleanup = true; statsCleanup: true,
context.args.statsCleanupVocab = true; statsCleanupVocab: true,
const forwarded: string[][] = []; });
const handled = await runStatsCommand(harness.context, {
const handled = await runStatsCommand(context, { ...harness.commandDeps,
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -247,72 +314,62 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
'--stats-cleanup-vocab', '--stats-cleanup-vocab',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats stop command forwards stop flag to the app', async () => { test('stats stop command forwards stop flag to the app', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true, statsStop: true });
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, { const handled = await runStatsCommand(harness.context, {
createTempDir: () => '/tmp/subminer-stats-test', ...harness.commandDeps,
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--stats-stop'], [
'--stats-daemon-stop',
'--stats-response-path',
'/tmp/subminer-stats-test/response.json',
],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats stop command exits on process exit without waiting for startup response', async () => { test('stats stop command exits on process exit without waiting for startup response', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true, statsStop: true });
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
let waitedForResponse = false; let waitedForResponse = false;
const handled = await runStatsCommand(context, { const handled = await runStatsCommand(harness.context, {
createTempDir: () => '/tmp/subminer-stats-test', ...harness.commandDeps,
joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async (...args) => {
runAppCommandAttached: async () => 0, await harness.runAppCommandAttachedStub(...args);
return 0;
},
waitForStatsResponse: async () => { waitForStatsResponse: async () => {
waitedForResponse = true; waitedForResponse = true;
return { ok: true }; return { ok: true };
}, },
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.equal(waitedForResponse, false); assert.equal(waitedForResponse, false);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => { test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
const context = createContext(); const harness = createStatsTestHarness({
context.args.stats = true; stats: true,
context.args.statsCleanup = true; statsCleanup: true,
context.args.statsCleanupLifetime = true; statsCleanupLifetime: true,
const forwarded: string[][] = []; });
const handled = await runStatsCommand(harness.context, {
const handled = await runStatsCommand(context, { ...harness.commandDeps,
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [ assert.deepEqual(harness.forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -321,42 +378,144 @@ test('stats cleanup command forwards lifetime rebuild flag to the app', async ()
'--stats-cleanup-lifetime', '--stats-cleanup-lifetime',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command throws when stats response reports an error', async () => { test('stats command throws when stats response reports an error', async () => {
const context = createContext(); const harness = createStatsTestHarness({ stats: true });
context.args.stats = true;
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(context, { await runStatsCommand(harness.context, {
createTempDir: () => '/tmp/subminer-stats-test', ...harness.commandDeps,
joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async (...args) => {
runAppCommandAttached: async () => 0, await harness.runAppCommandAttachedStub(...args);
return 0;
},
waitForStatsResponse: async () => ({ waitForStatsResponse: async () => ({
ok: false, ok: false,
error: 'Immersion tracking is disabled in config.', error: 'Immersion tracking is disabled in config.',
}), }),
removeDir: () => {},
}); });
}, /Immersion tracking is disabled in config\./); }, /Immersion tracking is disabled in config\./);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command fails if attached app exits before startup response', async () => { test('stats cleanup command fails if attached app exits before startup response', async () => {
const context = createContext(); const harness = createStatsTestHarness({
context.args.stats = true; stats: true,
context.args.statsCleanup = true; statsCleanup: true,
context.args.statsCleanupVocab = true; statsCleanupVocab: true,
});
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(context, { await runStatsCommand(harness.context, {
createTempDir: () => '/tmp/subminer-stats-test', ...harness.commandDeps,
joinPath: (...parts) => parts.join('/'), runAppCommandAttached: async (...args) => {
runAppCommandAttached: async () => 2, await harness.runAppCommandAttachedStub(...args);
return 2;
},
waitForStatsResponse: async () => { waitForStatsResponse: async () => {
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
return { ok: true, url: 'http://127.0.0.1:5175' }; return { ok: true, url: 'http://127.0.0.1:5175' };
}, },
removeDir: () => {},
}); });
}, /Stats app exited before startup response \(status 2\)\./); }, /Stats app exited before startup response \(status 2\)\./);
assert.equal(harness.removedPaths.length, 1);
});
test('stats command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({ stats: true });
let aborted = false;
await assert.rejects(async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
return 2;
},
waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
aborted = true;
resolve({ ok: false, error: 'aborted' });
},
{ once: true },
);
}),
});
}, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
const harness = createStatsTestHarness({ stats: true });
const spawnError = new Error('spawn failed');
let aborted = false;
await assert.rejects(
async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
throw spawnError;
},
waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
aborted = true;
resolve({ ok: false, error: 'aborted' });
},
{ once: true },
);
}),
});
},
(error: unknown) => error === spawnError,
);
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
});
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({
stats: true,
statsCleanup: true,
statsCleanupVocab: true,
});
let aborted = false;
await assert.rejects(async () => {
await runStatsCommand(harness.context, {
...harness.commandDeps,
runAppCommandAttached: async (...args) => {
await harness.runAppCommandAttachedStub(...args);
return 2;
},
waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
aborted = true;
resolve({ ok: false, error: 'aborted' });
},
{ once: true },
);
}),
});
}, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
}); });

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { log } from '../log.js'; import { log } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js'; import { commandExists } from '../util.js';
import { resolveMainConfigPath } from '../config-path.js'; import { resolveMainConfigPath } from '../config-path.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
@@ -8,12 +9,14 @@ interface DoctorCommandDeps {
commandExists(command: string): boolean; commandExists(command: string): boolean;
configExists(path: string): boolean; configExists(path: string): boolean;
resolveMainConfigPath(): string; resolveMainConfigPath(): string;
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
} }
const defaultDeps: DoctorCommandDeps = { const defaultDeps: DoctorCommandDeps = {
commandExists, commandExists,
configExists: fs.existsSync, configExists: fs.existsSync,
resolveMainConfigPath, resolveMainConfigPath,
runAppCommandWithInherit,
}; };
export function runDoctorCommand( export function runDoctorCommand(
@@ -72,14 +75,21 @@ export function runDoctorCommand(
}, },
]; ];
const hasHardFailure = checks.some((entry) =>
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
);
for (const check of checks) { for (const check of checks) {
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`); log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
} }
if (args.doctorRefreshKnownWords) {
if (!appPath) {
processAdapter.exit(1);
return true;
}
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
}
const hasHardFailure = checks.some((entry) =>
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
);
processAdapter.exit(hasHardFailure ? 1 : 0); processAdapter.exit(hasHardFailure ? 1 : 0);
return true; return true;
} }

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { launchAppCommandDetached, runAppCommandAttached } from '../mpv.js'; import { runAppCommandAttached } from '../mpv.js';
import { sleep } from '../util.js'; import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
@@ -20,28 +20,39 @@ type StatsCommandDeps = {
logLevel: LauncherCommandContext['args']['logLevel'], logLevel: LauncherCommandContext['args']['logLevel'],
label: string, label: string,
) => Promise<number>; ) => Promise<number>;
launchAppCommandDetached: ( waitForStatsResponse: (
appPath: string, responsePath: string,
appArgs: string[], signal?: AbortSignal,
logLevel: LauncherCommandContext['args']['logLevel'], ) => Promise<StatsCommandResponse>;
label: string,
) => void;
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
removeDir: (targetPath: string) => void; removeDir: (targetPath: string) => void;
}; };
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000; const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000;
type StatsResponseWait = {
controller: AbortController;
promise: Promise<{ kind: 'response'; response: StatsCommandResponse }>;
};
type StatsStartupResult =
| { kind: 'response'; response: StatsCommandResponse }
| { kind: 'exit'; status: number }
| { kind: 'spawn-error'; error: unknown };
const defaultDeps: StatsCommandDeps = { const defaultDeps: StatsCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts), joinPath: (...parts) => path.join(...parts),
runAppCommandAttached: (appPath, appArgs, logLevel, label) => runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
runAppCommandAttached(appPath, appArgs, logLevel, label), runAppCommandAttached(appPath, appArgs, logLevel, label),
launchAppCommandDetached: (appPath, appArgs, logLevel, label) => waitForStatsResponse: async (responsePath, signal) => {
launchAppCommandDetached(appPath, appArgs, logLevel, label),
waitForStatsResponse: async (responsePath) => {
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS; const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
while (Date.now() < deadline) { while (Date.now() < deadline) {
if (signal?.aborted) {
return {
ok: false,
error: 'Cancelled waiting for stats dashboard startup response.',
};
}
try { try {
if (fs.existsSync(responsePath)) { if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse; return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse;
@@ -61,6 +72,49 @@ const defaultDeps: StatsCommandDeps = {
}, },
}; };
async function performStartupHandshake(
createResponseWait: () => StatsResponseWait,
attachedExitPromise: Promise<number>,
): Promise<boolean> {
const responseWait = createResponseWait();
const startupResult = await Promise.race<StatsStartupResult>([
responseWait.promise,
attachedExitPromise.then(
(status) => ({ kind: 'exit' as const, status }),
(error) => ({ kind: 'spawn-error' as const, error }),
),
]);
if (startupResult.kind === 'spawn-error') {
responseWait.controller.abort();
throw startupResult.error;
}
if (startupResult.kind === 'exit') {
if (startupResult.status !== 0) {
responseWait.controller.abort();
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
}
const response = await responseWait.promise.then((result) => result.response);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
return true;
}
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
const exitStatus = await attachedExitPromise;
if (exitStatus !== 0) {
throw new Error(`Stats app exited with status ${exitStatus}.`);
}
return true;
}
export async function runStatsCommand( export async function runStatsCommand(
context: LauncherCommandContext, context: LauncherCommandContext,
deps: Partial<StatsCommandDeps> = {}, deps: Partial<StatsCommandDeps> = {},
@@ -74,14 +128,24 @@ export async function runStatsCommand(
const tempDir = resolvedDeps.createTempDir('subminer-stats-'); const tempDir = resolvedDeps.createTempDir('subminer-stats-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
const createResponseWait = () => {
const controller = new AbortController();
return {
controller,
promise: resolvedDeps
.waitForStatsResponse(responsePath, controller.signal)
.then((response) => ({ kind: 'response' as const, response })),
};
};
try { try {
const forwarded = ['--stats', '--stats-response-path', responsePath]; const forwarded = args.statsCleanup
if (args.statsBackground) { ? ['--stats', '--stats-response-path', responsePath]
forwarded.push('--stats-background'); : args.statsStop
} ? ['--stats-daemon-stop', '--stats-response-path', responsePath]
if (args.statsStop) { : args.statsBackground
forwarded.push('--stats-stop'); ? ['--stats-daemon-start', '--stats-response-path', responsePath]
} : ['--stats', '--stats-response-path', responsePath];
if (args.statsCleanup) { if (args.statsCleanup) {
forwarded.push('--stats-cleanup'); forwarded.push('--stats-cleanup');
} }
@@ -94,14 +158,6 @@ export async function runStatsCommand(
if (args.logLevel !== 'info') { if (args.logLevel !== 'info') {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
if (args.statsBackground) {
resolvedDeps.launchAppCommandDetached(appPath, forwarded, args.logLevel, 'stats');
const startupResult = await resolvedDeps.waitForStatsResponse(responsePath);
if (!startupResult.ok) {
throw new Error(startupResult.error || 'Stats dashboard failed to start.');
}
return true;
}
const attachedExitPromise = resolvedDeps.runAppCommandAttached( const attachedExitPromise = resolvedDeps.runAppCommandAttached(
appPath, appPath,
forwarded, forwarded,
@@ -117,59 +173,7 @@ export async function runStatsCommand(
return true; return true;
} }
if (!args.statsCleanup && !args.statsStop) { return await performStartupHandshake(createResponseWait, attachedExitPromise);
const startupResult = await Promise.race([
resolvedDeps
.waitForStatsResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
]);
if (startupResult.kind === 'exit') {
if (startupResult.status !== 0) {
throw new Error(
`Stats app exited before startup response (status ${startupResult.status}).`,
);
}
const response = await resolvedDeps.waitForStatsResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
return true;
}
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
await attachedExitPromise;
return true;
}
const attachedExitPromiseCleanup = attachedExitPromise;
const startupResult = await Promise.race([
resolvedDeps
.waitForStatsResponse(responsePath)
.then((response) => ({ kind: 'response' as const, response })),
attachedExitPromiseCleanup.then((status) => ({ kind: 'exit' as const, status })),
]);
if (startupResult.kind === 'exit') {
if (startupResult.status !== 0) {
throw new Error(
`Stats app exited before startup response (status ${startupResult.status}).`,
);
}
const response = await resolvedDeps.waitForStatsResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'Stats dashboard failed to start.');
}
return true;
}
if (!startupResult.response.ok) {
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
}
const exitStatus = await attachedExitPromiseCleanup;
if (exitStatus !== 0) {
throw new Error(`Stats app exited with status ${exitStatus}.`);
}
return true;
} finally { } finally {
resolvedDeps.removeDir(tempDir); resolvedDeps.removeDir(tempDir);
} }

View File

@@ -129,6 +129,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
@@ -206,6 +207,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget); parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} }
if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.jellyfinInvocation) { if (invocations.jellyfinInvocation) {

View File

@@ -49,6 +49,7 @@ export interface CliInvocations {
statsLogLevel: string | null; statsLogLevel: string | null;
doctorTriggered: boolean; doctorTriggered: boolean;
doctorLogLevel: string | null; doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean; texthookerTriggered: boolean;
texthookerLogLevel: string | null; texthookerLogLevel: string | null;
} }
@@ -156,6 +157,7 @@ export function parseCliPrograms(
let statsCleanupLifetime = false; let statsCleanupLifetime = false;
let statsLogLevel: string | null = null; let statsLogLevel: string | null = null;
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
let doctorTriggered = false; let doctorTriggered = false;
let texthookerTriggered = false; let texthookerTriggered = false;
@@ -276,9 +278,25 @@ export function parseCliPrograms(
if (statsBackground && statsStop) { if (statsBackground && statsStop) {
throw new Error('Stats background and stop flags cannot be combined.'); throw new Error('Stats background and stop flags cannot be combined.');
} }
if (
normalizedAction &&
normalizedAction !== 'cleanup' &&
normalizedAction !== 'rebuild' &&
normalizedAction !== 'backfill'
) {
throw new Error(
'Invalid stats action. Valid values are cleanup, rebuild, or backfill.',
);
}
if (normalizedAction && (statsBackground || statsStop)) { if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.'); throw new Error('Stats background and stop flags cannot be combined with stats actions.');
} }
if (
normalizedAction !== 'cleanup' &&
(options.vocab === true || options.lifetime === true)
) {
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
}
if (normalizedAction === 'cleanup') { if (normalizedAction === 'cleanup') {
statsCleanup = true; statsCleanup = true;
statsCleanupLifetime = options.lifetime === true; statsCleanupLifetime = options.lifetime === true;
@@ -294,10 +312,12 @@ export function parseCliPrograms(
commandProgram commandProgram
.command('doctor') .command('doctor')
.description('Run dependency and environment checks') .description('Run dependency and environment checks')
.option('--refresh-known-words', 'Refresh known words cache')
.option('--log-level <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => { .action((options: Record<string, unknown>) => {
doctorTriggered = true; doctorTriggered = true;
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
doctorRefreshKnownWords = options.refreshKnownWords === true;
}); });
commandProgram commandProgram
@@ -378,6 +398,7 @@ export function parseCliPrograms(
statsLogLevel, statsLogLevel,
doctorTriggered, doctorTriggered,
doctorLogLevel, doctorLogLevel,
doctorRefreshKnownWords,
texthookerTriggered, texthookerTriggered,
texthookerLogLevel, texthookerLogLevel,
}, },

View File

@@ -178,6 +178,33 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
}); });
}); });
test('doctor refresh-known-words forwards app refresh command without requiring mpv', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: '',
Path: '',
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['doctor', '--refresh-known-words'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--refresh-known-words\n');
assert.match(result.stdout, /\[doctor\] mpv: missing/);
});
});
test('youtube command rejects removed --mode option', () => { test('youtube command rejects removed --mode option', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');
@@ -387,6 +414,76 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
}); });
}); });
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const binDir = path.join(root, 'bin');
const appPath = path.join(root, 'fake-subminer.sh');
const videoPath = path.join(root, 'movie.mkv');
const mpvArgsPath = path.join(root, 'mpv-args.txt');
const socketPath = path.join(root, 'mpv.sock');
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(videoPath, 'fake video content');
fs.writeFileSync(
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
JSON.stringify({
version: 1,
status: 'completed',
completedAt: '2026-03-08T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
}),
);
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(
path.join(binDir, 'mpv'),
`#!/bin/sh
set -eu
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
socket_path=""
for arg in "$@"; do
case "$arg" in
--input-ipc-server=*)
socket_path="\${arg#--input-ipc-server=}"
;;
esac
done
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
`,
'utf8',
);
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
};
const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),
/--script-opts=.*subminer-log_level=debug/,
);
});
});
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');

View File

@@ -9,14 +9,46 @@ import type { Args } from './types';
import { import {
cleanupPlaybackSession, cleanupPlaybackSession,
findAppBinary, findAppBinary,
launchAppCommandDetached,
launchTexthookerOnly,
parseMpvArgString,
runAppCommandCaptureOutput, runAppCommandCaptureOutput,
shouldResolveAniSkipMetadata, shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay, startOverlay,
state, state,
waitForUnixSocketReady, waitForUnixSocketReady,
} from './mpv'; } from './mpv';
import * as mpvModule from './mpv'; import * as mpvModule from './mpv';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected process.exit');
}
function createTempSocketPath(): { dir: string; socketPath: string } { function createTempSocketPath(): { dir: string; socketPath: string } {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests'); const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
@@ -40,6 +72,94 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
assert.equal(result.error, undefined); assert.equal(result.error, undefined);
}); });
test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => {
const original = process.env.ELECTRON_RUN_AS_NODE;
try {
process.env.ELECTRON_RUN_AS_NODE = '1';
const result = runAppCommandCaptureOutput(process.execPath, [
'-e',
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
]);
assert.equal(result.status, 0);
assert.equal(result.stdout, '');
} finally {
if (original === undefined) {
delete process.env.ELECTRON_RUN_AS_NODE;
} else {
process.env.ELECTRON_RUN_AS_NODE = original;
}
}
});
test('parseMpvArgString preserves empty quoted tokens', () => {
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
'--title',
'',
'--force-media-title',
'',
'--pause',
]);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
});
assert.equal(error.code, 1);
});
test('launchAppCommandDetached handles child process spawn errors', async () => {
let uncaughtError: Error | null = null;
const onUncaughtException = (error: Error) => {
uncaughtError = error;
};
process.once('uncaughtException', onUncaughtException);
try {
launchAppCommandDetached(
'/definitely-missing-subminer-binary',
[],
makeArgs({ logLevel: 'warn' }).logLevel,
'test',
);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(uncaughtError, null);
} finally {
process.removeListener('uncaughtException', onUncaughtException);
}
});
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
const originalWrite = process.stdout.write;
const writes: string[] = [];
const overlayProc = {
killed: false,
kill: () => true,
} as unknown as NonNullable<typeof state.overlayProc>;
try {
process.stdout.write = ((chunk: string | Uint8Array) => {
writes.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk));
return true;
}) as typeof process.stdout.write;
state.stopRequested = false;
state.overlayManagedByLauncher = true;
state.appPath = '/definitely-missing-subminer-binary';
state.overlayProc = overlayProc;
stopOverlay(makeArgs({ logLevel: 'warn' }));
assert.ok(writes.some((text) => text.includes('Failed to stop SubMiner overlay')));
} finally {
process.stdout.write = originalWrite;
state.stopRequested = false;
state.overlayManagedByLauncher = false;
state.appPath = '';
state.overlayProc = null;
}
});
test('waitForUnixSocketReady returns false when socket never appears', async () => { test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath(); const { dir, socketPath } = createTempSocketPath();
try { try {
@@ -137,6 +257,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
dictionary: false, dictionary: false,
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
@@ -245,6 +366,44 @@ function makeExecutable(filePath: string): void {
fs.chmodSync(filePath, 0o755); fs.chmodSync(filePath, 0o755);
} }
function withFindAppBinaryEnvSandbox(run: () => void): void {
const originalAppImagePath = process.env.SUBMINER_APPIMAGE_PATH;
const originalBinaryPath = process.env.SUBMINER_BINARY_PATH;
try {
delete process.env.SUBMINER_APPIMAGE_PATH;
delete process.env.SUBMINER_BINARY_PATH;
run();
} finally {
if (originalAppImagePath === undefined) {
delete process.env.SUBMINER_APPIMAGE_PATH;
} else {
process.env.SUBMINER_APPIMAGE_PATH = originalAppImagePath;
}
if (originalBinaryPath === undefined) {
delete process.env.SUBMINER_BINARY_PATH;
} else {
process.env.SUBMINER_BINARY_PATH = originalBinaryPath;
}
}
}
function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void {
const originalAccessSync = fs.accessSync;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).accessSync = (filePath: string): void => {
if (isExecutablePath(filePath)) {
return;
}
throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' });
};
run();
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).accessSync = originalAccessSync;
}
}
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => { test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir; const originalHomedir = os.homedir;
@@ -253,8 +412,10 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', ()
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage'); const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage); makeExecutable(appImage);
const result = findAppBinary('/some/other/path/subminer'); withFindAppBinaryEnvSandbox(() => {
assert.equal(result, appImage); const result = findAppBinary('/some/other/path/subminer');
assert.equal(result, appImage);
});
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
@@ -264,22 +425,16 @@ test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', ()
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => { test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-')); const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir; const originalHomedir = os.homedir;
const originalAccessSync = fs.accessSync;
try { try {
os.homedir = () => baseDir; os.homedir = () => baseDir;
// No ~/.local/bin/SubMiner.AppImage; patch accessSync so only /opt path is executable withFindAppBinaryEnvSandbox(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => {
(fs as any).accessSync = (filePath: string, mode?: number): void => { const result = findAppBinary('/some/other/path/subminer');
if (filePath === '/opt/SubMiner/SubMiner.AppImage') return; assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' }); });
}; });
const result = findAppBinary('/some/other/path/subminer');
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).accessSync = originalAccessSync;
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); });
@@ -296,9 +451,13 @@ test('findAppBinary finds subminer on PATH when AppImage candidates do not exist
makeExecutable(wrapperPath); makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
// selfPath must differ from wrapperPath so the self-check does not exclude it withFindAppBinaryEnvSandbox(() => {
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer')); withAccessSyncStub((filePath) => filePath === wrapperPath, () => {
assert.equal(result, wrapperPath); // selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
assert.equal(result, wrapperPath);
});
});
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;
process.env.PATH = originalPath; process.env.PATH = originalPath;

View File

@@ -42,14 +42,18 @@ export function parseMpvArgString(input: string): string[] {
const chars = input; const chars = input;
const args: string[] = []; const args: string[] = [];
let current = ''; let current = '';
let tokenStarted = false;
let inSingleQuote = false; let inSingleQuote = false;
let inDoubleQuote = false; let inDoubleQuote = false;
let escaping = false; let escaping = false;
const canEscape = (nextChar: string | undefined): boolean =>
nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar);
for (let i = 0; i < chars.length; i += 1) { for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i] || ''; const ch = chars[i] || '';
if (escaping) { if (escaping) {
current += ch; current += ch;
tokenStarted = true;
escaping = false; escaping = false;
continue; continue;
} }
@@ -59,13 +63,19 @@ export function parseMpvArgString(input: string): string[] {
inSingleQuote = false; inSingleQuote = false;
} else { } else {
current += ch; current += ch;
tokenStarted = true;
} }
continue; continue;
} }
if (inDoubleQuote) { if (inDoubleQuote) {
if (ch === '\\') { if (ch === '\\') {
escaping = true; if (canEscape(chars[i + 1])) {
escaping = true;
} else {
current += ch;
tokenStarted = true;
}
continue; continue;
} }
if (ch === '"') { if (ch === '"') {
@@ -73,29 +83,40 @@ export function parseMpvArgString(input: string): string[] {
continue; continue;
} }
current += ch; current += ch;
tokenStarted = true;
continue; continue;
} }
if (ch === '\\') { if (ch === '\\') {
escaping = true; if (canEscape(chars[i + 1])) {
escaping = true;
tokenStarted = true;
} else {
current += ch;
tokenStarted = true;
}
continue; continue;
} }
if (ch === "'") { if (ch === "'") {
tokenStarted = true;
inSingleQuote = true; inSingleQuote = true;
continue; continue;
} }
if (ch === '"') { if (ch === '"') {
tokenStarted = true;
inDoubleQuote = true; inDoubleQuote = true;
continue; continue;
} }
if (/\s/.test(ch)) { if (/\s/.test(ch)) {
if (current) { if (tokenStarted) {
args.push(current); args.push(current);
current = ''; current = '';
tokenStarted = false;
} }
continue; continue;
} }
current += ch; current += ch;
tokenStarted = true;
} }
if (escaping) { if (escaping) {
@@ -104,7 +125,7 @@ export function parseMpvArgString(input: string): string[] {
if (inSingleQuote || inDoubleQuote) { if (inSingleQuote || inDoubleQuote) {
fail('Could not parse mpv args: unmatched quote'); fail('Could not parse mpv args: unmatched quote');
} }
if (current) { if (tokenStarted) {
args.push(current); args.push(current);
} }
@@ -576,7 +597,7 @@ export async function startMpv(
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
? await resolveAniSkipMetadataForFile(target) ? await resolveAniSkipMetadataForFile(target)
: null; : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
if (aniSkipMetadata) { if (aniSkipMetadata) {
log( log(
'debug', 'debug',
@@ -651,7 +672,7 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
const target = resolveAppSpawnTarget(appPath, overlayArgs); const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, { state.overlayProc = spawn(target.command, target.args, {
stdio: 'inherit', stdio: 'inherit',
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() }, env: buildAppEnv(),
}); });
state.overlayManagedByLauncher = true; state.overlayManagedByLauncher = true;
@@ -678,7 +699,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, { stdio: 'inherit' }); const result = spawnSync(appPath, overlayArgs, {
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`);
}
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} }
@@ -692,7 +719,15 @@ export function stopOverlay(args: Args): void {
const stopArgs = ['--stop']; const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
spawnSync(state.appPath, stopArgs, { stdio: 'ignore' }); const result = spawnSync(state.appPath, stopArgs, {
stdio: 'ignore',
env: buildAppEnv(),
});
if (result.error) {
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
} else if (typeof result.status === 'number' && result.status !== 0) {
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
}
if (state.overlayProc && !state.overlayProc.killed) { if (state.overlayProc && !state.overlayProc.killed) {
try { try {
@@ -753,6 +788,7 @@ function buildAppEnv(): NodeJS.ProcessEnv {
...process.env, ...process.env,
SUBMINER_MPV_LOG: getMpvLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(),
}; };
delete env.ELECTRON_RUN_AS_NODE;
const layers = env.VK_INSTANCE_LAYERS; const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === 'string' && layers.trim().length > 0) { if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers const filtered = layers
@@ -857,8 +893,14 @@ export function runAppCommandAttached(
proc.once('error', (error) => { proc.once('error', (error) => {
reject(error); reject(error);
}); });
proc.once('exit', (code) => { proc.once('exit', (code, signal) => {
resolve(code ?? 0); if (code !== null) {
resolve(code);
} else if (signal) {
resolve(128);
} else {
resolve(0);
}
}); });
}); });
} }
@@ -916,6 +958,9 @@ export function launchAppCommandDetached(
detached: true, detached: true,
env: buildAppEnv(), env: buildAppEnv(),
}); });
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref(); proc.unref();
} }
@@ -939,9 +984,7 @@ export function launchMpvIdleDetached(
mpvArgs.push(...parseMpvArgString(args.mpvArgs)); mpvArgs.push(...parseMpvArgString(args.mpvArgs));
} }
mpvArgs.push('--idle=yes'); mpvArgs.push('--idle=yes');
mpvArgs.push( mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);

View File

@@ -2,6 +2,34 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { parseArgs } from './config'; import { parseArgs } from './config';
class ExitSignal extends Error {
code: number;
constructor(code: number) {
super(`exit:${code}`);
this.code = code;
}
}
function withProcessExitIntercept(callback: () => void): ExitSignal {
const originalExit = process.exit;
try {
process.exit = ((code?: number) => {
throw new ExitSignal(code ?? 0);
}) as typeof process.exit;
callback();
} catch (error) {
if (error instanceof ExitSignal) {
return error;
}
throw error;
} finally {
process.exit = originalExit;
}
throw new Error('expected parseArgs to exit');
}
test('parseArgs captures passthrough args for app subcommand', () => { test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
@@ -119,6 +147,15 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
assert.equal(parsed.statsCleanupLifetime, true); assert.equal(parsed.statsCleanupLifetime, true);
}); });
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', '--vocab'], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.message, /exit:1/);
});
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => { test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {}); const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
@@ -127,3 +164,10 @@ test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
assert.equal(parsed.statsCleanupVocab, false); assert.equal(parsed.statsCleanupVocab, false);
assert.equal(parsed.statsCleanupLifetime, true); assert.equal(parsed.statsCleanupLifetime, true);
}); });
test('parseArgs maps doctor refresh-known-words flag', () => {
const parsed = parseArgs(['doctor', '--refresh-known-words'], 'subminer', {});
assert.equal(parsed.doctor, true);
assert.equal(parsed.doctorRefreshKnownWords, true);
});

View File

@@ -14,6 +14,20 @@ function makeFile(filePath: string): void {
fs.writeFileSync(filePath, '/* theme */'); fs.writeFileSync(filePath, '/* theme */');
} }
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
value: platform,
});
try {
return callback();
} finally {
if (originalDescriptor) {
Object.defineProperty(process, 'platform', originalDescriptor);
}
}
}
test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => { test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => {
const originalExistsSync = fs.existsSync; const originalExistsSync = fs.existsSync;
const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`; const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
@@ -24,7 +38,7 @@ test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when
return false; return false;
}; };
const result = findRofiTheme('/usr/local/bin/subminer'); const result = withPlatform('linux', () => findRofiTheme('/usr/local/bin/subminer'));
assert.equal(result, targetPath); assert.equal(result, targetPath);
} finally { } finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -44,7 +58,7 @@ test('findRofiTheme resolves /usr/share/SubMiner/themes/subminer.rasi when /usr/
return false; return false;
}; };
const result = findRofiTheme('/usr/bin/subminer'); const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, sharePath); assert.equal(result, sharePath);
} finally { } finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -60,10 +74,14 @@ test('findRofiTheme resolves XDG_DATA_HOME/SubMiner/themes/subminer.rasi when se
const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`); const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath); makeFile(themePath);
const result = findRofiTheme('/usr/bin/subminer'); const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath); assert.equal(result, themePath);
} finally { } finally {
process.env.XDG_DATA_HOME = originalXdgDataHome; if (originalXdgDataHome !== undefined) {
process.env.XDG_DATA_HOME = originalXdgDataHome;
} else {
delete process.env.XDG_DATA_HOME;
}
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); });
@@ -78,7 +96,7 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`); const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`);
makeFile(themePath); makeFile(themePath);
const result = findRofiTheme('/usr/bin/subminer'); const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
assert.equal(result, themePath); assert.equal(result, themePath);
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;

View File

@@ -119,6 +119,7 @@ export interface Args {
statsCleanupLifetime?: boolean; statsCleanupLifetime?: boolean;
dictionaryTarget?: string; dictionaryTarget?: string;
doctor: boolean; doctor: boolean;
doctorRefreshKnownWords: boolean;
configPath: boolean; configPath: boolean;
configShow: boolean; configShow: boolean;
mpvIdle: boolean; mpvIdle: boolean;

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.6.5", "version": "0.7.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -372,12 +372,9 @@ function M.create(ctx)
end) end)
end end
launch_overlay_with_retry(1)
if texthooker_enabled then if texthooker_enabled then
ensure_texthooker_running(function() ensure_texthooker_running(function() end)
launch_overlay_with_retry(1)
end)
else
launch_overlay_with_retry(1)
end end
end end
@@ -481,31 +478,33 @@ function M.create(ctx)
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
ensure_texthooker_running(function() local start_args = build_command_args("start")
local start_args = build_command_args("start") subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
state.overlay_running = true state.overlay_running = true
mp.command_native_async({ mp.command_native_async({
name = "subprocess", name = "subprocess",
args = start_args, args = start_args,
playback_only = false, playback_only = false,
capture_stdout = true, capture_stdout = true,
capture_stderr = true, capture_stderr = true,
}, function(success, result, error) }, function(success, result, error)
if not success or (result and result.status ~= 0) then if not success or (result and result.status ~= 0) then
state.overlay_running = false state.overlay_running = false
subminer_log( subminer_log(
"error", "error",
"process", "process",
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
) )
show_osd("Restart failed") show_osd("Restart failed")
else else
show_osd("Restarted successfully") show_osd("Restarted successfully")
end end
end)
end) end)
if opts.texthooker_enabled then
ensure_texthooker_running(function() end)
end
end) end)
end end

View File

@@ -344,6 +344,27 @@ local function count_start_calls(async_calls)
return count return count
end end
local function find_texthooker_call(async_calls)
for _, call in ipairs(async_calls) do
local args = call.args or {}
for i = 1, #args do
if args[i] == "--texthooker" then
return call
end
end
end
return nil
end
local function find_call_index(async_calls, target_call)
for index, call in ipairs(async_calls) do
if call == target_call then
return index
end
end
return nil
end
local function find_control_call(async_calls, flag) local function find_control_call(async_calls, flag)
for _, call in ipairs(async_calls) do for _, call in ipairs(async_calls) do
local args = call.args or {} local args = call.args or {}
@@ -643,6 +664,8 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
local texthooker_call = find_texthooker_call(recorded.async_calls)
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled")
assert_true( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should include --show-visible-overlay on --start" "auto-start with visible overlay enabled should include --show-visible-overlay on --start"
@@ -655,6 +678,10 @@ do
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" "auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
) )
assert_true(
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
"auto-start should launch --start before separate --texthooker helper startup"
)
assert_true( assert_true(
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option" "auto-start visible overlay should not force pause without explicit pause-until-ready option"

50
src/anki-connect.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnkiConnectClient } from './anki-connect';
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: {
defaults: {
httpAgent?: { options?: { keepAlive?: boolean } };
httpsAgent?: { options?: { keepAlive?: boolean } };
};
};
};
assert.equal(client.client.defaults.httpAgent?.options?.keepAlive, false);
assert.equal(client.client.defaults.httpsAgent?.options?.keepAlive, false);
});
test('AnkiConnectClient includes action name in retry logs', async () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
client: { post: (url: string, body: unknown, options: unknown) => Promise<unknown> };
sleep: (ms: number) => Promise<void>;
};
let shouldFail = true;
client.client = {
post: async () => {
if (shouldFail) {
shouldFail = false;
const error = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' });
throw error;
}
return { data: { result: [], error: null } };
},
};
client.sleep = async () => undefined;
const originalInfo = console.info;
const messages: string[] = [];
try {
console.info = (...args: unknown[]) => {
messages.push(args.map((value) => String(value)).join(' '));
};
await (client as unknown as AnkiConnectClient).invoke('notesInfo', { notes: [1] });
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
} finally {
console.info = originalInfo;
}
});

View File

@@ -43,7 +43,7 @@ export class AnkiConnectClient {
constructor(url: string) { constructor(url: string) {
const httpAgent = new http.Agent({ const httpAgent = new http.Agent({
keepAlive: true, keepAlive: false,
keepAliveMsecs: 1000, keepAliveMsecs: 1000,
maxSockets: 5, maxSockets: 5,
maxFreeSockets: 2, maxFreeSockets: 2,
@@ -51,7 +51,7 @@ export class AnkiConnectClient {
}); });
const httpsAgent = new https.Agent({ const httpsAgent = new https.Agent({
keepAlive: true, keepAlive: false,
keepAliveMsecs: 1000, keepAliveMsecs: 1000,
maxSockets: 5, maxSockets: 5,
maxFreeSockets: 2, maxFreeSockets: 2,
@@ -106,7 +106,7 @@ export class AnkiConnectClient {
try { try {
if (attempt > 0) { if (attempt > 0) {
const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs); const delay = Math.min(this.backoffMs * Math.pow(2, attempt - 1), this.maxBackoffMs);
log.info(`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`); log.info(`AnkiConnect ${action} retry ${attempt}/${maxRetries} after ${delay}ms delay`);
await this.sleep(delay); await this.sleep(delay);
} }

View File

@@ -250,6 +250,34 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
assert.equal(privateState.runtime.proxyServer, null); assert.equal(privateState.runtime.proxyServer, null);
}); });
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
const osdMessages: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'osd',
},
},
{} as never,
{} as never,
(text) => {
osdMessages.push(text);
},
);
await (
integration as unknown as {
showNotification: (
noteId: number,
label: string | number,
errorSuffix?: string,
) => Promise<void>;
}
).showNotification(42, 'taberu', 'image failed');
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
});
test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => { test('FieldGroupingMergeCollaborator synchronizes ExpressionAudio from merged SentenceAudio', async () => {
const collaborator = createFieldGroupingMergeCollaborator(); const collaborator = createFieldGroupingMergeCollaborator();

View File

@@ -40,8 +40,10 @@ import { createLogger } from './logger';
import { import {
createUiFeedbackState, createUiFeedbackState,
beginUpdateProgress, beginUpdateProgress,
clearUpdateProgress,
endUpdateProgress, endUpdateProgress,
showStatusNotification, showStatusNotification,
showUpdateResult,
withUpdateProgress, withUpdateProgress,
UiFeedbackState, UiFeedbackState,
} from './anki-integration/ui-feedback'; } from './anki-integration/ui-feedback';
@@ -54,6 +56,7 @@ import { FieldGroupingService } from './anki-integration/field-grouping';
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge'; import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow'; import { NoteUpdateWorkflow } from './anki-integration/note-update-workflow';
import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow'; import { FieldGroupingWorkflow } from './anki-integration/field-grouping-workflow';
import { resolveAnimatedImageLeadInSeconds } from './anki-integration/animated-image-sync';
import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime'; import { AnkiIntegrationRuntime, normalizeAnkiIntegrationConfig } from './anki-integration/runtime';
const log = createLogger('anki').child('integration'); const log = createLogger('anki').child('integration');
@@ -190,12 +193,31 @@ export class AnkiIntegration {
this.resolveNoteFieldName(noteInfo, preferredName), this.resolveNoteFieldName(noteInfo, preferredName),
extractFields: (fields) => this.extractFields(fields), extractFields: (fields) => this.extractFields(fields),
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
generateMediaForMerge: () => this.generateMediaForMerge(), generateMediaForMerge: (noteInfo) => this.generateMediaForMerge(noteInfo),
warnFieldParseOnce: (fieldName, reason, detail) => warnFieldParseOnce: (fieldName, reason, detail) =>
this.warnFieldParseOnce(fieldName, reason, detail), this.warnFieldParseOnce(fieldName, reason, detail),
}); });
} }
private recordCardsMinedSafely(
count: number,
noteIds: number[] | undefined,
source: string,
): void {
if (!this.recordCardsMinedCallback) {
return;
}
try {
this.recordCardsMinedCallback(count, noteIds);
} catch (error) {
log.warn(
`recordCardsMined callback failed during ${source}:`,
(error as Error).message,
);
}
}
private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager { private createKnownWordCache(knownWordCacheStatePath?: string): KnownWordCacheManager {
return new KnownWordCacheManager({ return new KnownWordCacheManager({
client: { client: {
@@ -218,7 +240,7 @@ export class AnkiIntegration {
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
processNewCard: (noteId) => this.processNewCard(noteId), processNewCard: (noteId) => this.processNewCard(noteId),
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedCallback?.(count, noteIds); this.recordCardsMinedSafely(count, noteIds, 'polling');
}, },
isUpdateInProgress: () => this.updateInProgress, isUpdateInProgress: () => this.updateInProgress,
setUpdateInProgress: (value) => { setUpdateInProgress: (value) => {
@@ -242,7 +264,7 @@ export class AnkiIntegration {
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
processNewCard: (noteId: number) => this.processNewCard(noteId), processNewCard: (noteId: number) => this.processNewCard(noteId),
recordCardsAdded: (count, noteIds) => { recordCardsAdded: (count, noteIds) => {
this.recordCardsMinedCallback?.(count, noteIds); this.recordCardsMinedSafely(count, noteIds, 'proxy');
}, },
getDeck: () => this.config.deck, getDeck: () => this.config.deck,
findNotes: async (query, options) => findNotes: async (query, options) =>
@@ -286,6 +308,7 @@ export class AnkiIntegration {
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data), storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
findNotes: async (query, options) => findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[], (await this.client.findNotes(query, options)) as number[],
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) => generateAudio: (videoPath, startTime, endTime, audioPadding, audioStreamIndex) =>
@@ -308,6 +331,8 @@ export class AnkiIntegration {
), ),
}, },
showOsdNotification: (text: string) => this.showOsdNotification(text), showOsdNotification: (text: string) => this.showOsdNotification(text),
showUpdateResult: (message: string, success: boolean) =>
this.showUpdateResult(message, success),
showStatusNotification: (message: string) => this.showStatusNotification(message), showStatusNotification: (message: string) => this.showStatusNotification(message),
showNotification: (noteId, label, errorSuffix) => showNotification: (noteId, label, errorSuffix) =>
this.showNotification(noteId, label, errorSuffix), this.showNotification(noteId, label, errorSuffix),
@@ -319,6 +344,7 @@ export class AnkiIntegration {
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
resolveNoteFieldName: (noteInfo, preferredName) => resolveNoteFieldName: (noteInfo, preferredName) =>
this.resolveNoteFieldName(noteInfo, preferredName), this.resolveNoteFieldName(noteInfo, preferredName),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
extractFields: (fields) => this.extractFields(fields), extractFields: (fields) => this.extractFields(fields),
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) => setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
@@ -337,6 +363,9 @@ export class AnkiIntegration {
trackLastAddedNoteId: (noteId) => { trackLastAddedNoteId: (noteId) => {
this.previousNoteIds.add(noteId); this.previousNoteIds.add(noteId);
}, },
recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation');
},
}); });
} }
@@ -407,12 +436,13 @@ export class AnkiIntegration {
this.resolveConfiguredFieldName(noteInfo, ...preferredNames), this.resolveConfiguredFieldName(noteInfo, ...preferredNames),
getResolvedSentenceAudioFieldName: (noteInfo) => getResolvedSentenceAudioFieldName: (noteInfo) =>
this.getResolvedSentenceAudioFieldName(noteInfo), this.getResolvedSentenceAudioFieldName(noteInfo),
getAnimatedImageLeadInSeconds: (noteInfo) => this.getAnimatedImageLeadInSeconds(noteInfo),
mergeFieldValue: (existing, newValue, overwrite) => mergeFieldValue: (existing, newValue, overwrite) =>
this.mergeFieldValue(existing, newValue, overwrite), this.mergeFieldValue(existing, newValue, overwrite),
generateAudioFilename: () => this.generateAudioFilename(), generateAudioFilename: () => this.generateAudioFilename(),
generateAudio: () => this.generateAudio(), generateAudio: () => this.generateAudio(),
generateImageFilename: () => this.generateImageFilename(), generateImageFilename: () => this.generateImageFilename(),
generateImage: () => this.generateImage(), generateImage: (animatedLeadInSeconds) => this.generateImage(animatedLeadInSeconds),
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) => formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds), this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId), addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
@@ -637,7 +667,7 @@ export class AnkiIntegration {
); );
} }
private async generateImage(): Promise<Buffer | null> { private async generateImage(animatedLeadInSeconds = 0): Promise<Buffer | null> {
if (!this.mpvClient || !this.mpvClient.currentVideoPath) { if (!this.mpvClient || !this.mpvClient.currentVideoPath) {
return null; return null;
} }
@@ -665,6 +695,7 @@ export class AnkiIntegration {
maxWidth: this.config.media?.animatedMaxWidth, maxWidth: this.config.media?.animatedMaxWidth,
maxHeight: this.config.media?.animatedMaxHeight, maxHeight: this.config.media?.animatedMaxHeight,
crf: this.config.media?.animatedCrf, crf: this.config.media?.animatedCrf,
leadingStillDuration: animatedLeadInSeconds,
}, },
); );
} else { } else {
@@ -768,6 +799,12 @@ export class AnkiIntegration {
}); });
} }
private clearUpdateProgress(): void {
clearUpdateProgress(this.uiFeedbackState, (timer) => {
clearInterval(timer);
});
}
private async withUpdateProgress<T>( private async withUpdateProgress<T>(
initialMessage: string, initialMessage: string,
action: () => Promise<T>, action: () => Promise<T>,
@@ -898,7 +935,9 @@ export class AnkiIntegration {
const type = this.config.behavior?.notificationType || 'osd'; const type = this.config.behavior?.notificationType || 'osd';
if (type === 'osd' || type === 'both') { if (type === 'osd' || type === 'both') {
this.showOsdNotification(message); this.showUpdateResult(message, errorSuffix === undefined);
} else {
this.clearUpdateProgress();
} }
if ((type === 'system' || type === 'both') && this.notificationCallback) { if ((type === 'system' || type === 'both') && this.notificationCallback) {
@@ -933,6 +972,21 @@ export class AnkiIntegration {
} }
} }
private showUpdateResult(message: string, success: boolean): void {
showUpdateResult(
this.uiFeedbackState,
{
clearProgressTimer: (timer) => {
clearInterval(timer);
},
showOsdNotification: (text) => {
this.showOsdNotification(text);
},
},
{ message, success },
);
}
private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string { private mergeFieldValue(existing: string, newValue: string, overwrite: boolean): string {
if (overwrite || !existing.trim()) { if (overwrite || !existing.trim()) {
return newValue; return newValue;
@@ -1016,11 +1070,18 @@ export class AnkiIntegration {
return getConfiguredWordFieldCandidates(this.config); return getConfiguredWordFieldCandidates(this.config);
} }
private getPreferredWordValue(fields: Record<string, string>): string { private async getAnimatedImageLeadInSeconds(noteInfo: NoteInfo): Promise<number> {
return getPreferredWordValueFromExtractedFields(fields, this.config); return resolveAnimatedImageLeadInSeconds({
config: this.config,
noteInfo,
resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) =>
this.resolveConfiguredFieldName(candidateNoteInfo, ...preferredNames),
retrieveMediaFileBase64: (filename) => this.client.retrieveMediaFile(filename),
logWarn: (message, ...args) => log.warn(message, ...args),
});
} }
private async generateMediaForMerge(): Promise<{ private async generateMediaForMerge(noteInfo?: NoteInfo): Promise<{
audioField?: string; audioField?: string;
audioValue?: string; audioValue?: string;
imageField?: string; imageField?: string;
@@ -1057,8 +1118,11 @@ export class AnkiIntegration {
if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) { if (this.config.media?.generateImage && this.mpvClient?.currentVideoPath) {
try { try {
const animatedLeadInSeconds = noteInfo
? await this.getAnimatedImageLeadInSeconds(noteInfo)
: 0;
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImage(); const imageBuffer = await this.generateImage(animatedLeadInSeconds);
if (imageBuffer) { if (imageBuffer) {
await this.client.storeMediaFile(imageFilename, imageBuffer); await this.client.storeMediaFile(imageFilename, imageBuffer);
result.imageField = this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image; result.imageField = this.config.fields?.image || DEFAULT_ANKI_CONNECT_CONFIG.fields.image;

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveAnimatedImageLeadInSeconds, extractSoundFilenames } from './animated-image-sync';
test('extractSoundFilenames returns ordered sound filenames from an Anki field value', () => {
assert.deepEqual(
extractSoundFilenames('before [sound:word.mp3] middle [sound:alt.ogg] after'),
['word.mp3', 'alt.ogg'],
);
});
test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for animated images', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: false,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3]',
},
},
},
resolveConfiguredFieldName: () => 'ExpressionAudio',
retrieveMediaFileBase64: async () => {
throw new Error('should not be called');
},
probeAudioDurationSeconds: async () => {
throw new Error('should not be called');
},
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 0);
});

View File

@@ -0,0 +1,133 @@
import { execFile as nodeExecFile } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types';
type NoteInfoLike = {
noteId: number;
fields: Record<string, { value: string }>;
};
interface ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo extends NoteInfoLike> {
config: Pick<AnkiConnectConfig, 'fields' | 'media'>;
noteInfo: TNoteInfo;
resolveConfiguredFieldName: (
noteInfo: TNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
retrieveMediaFileBase64: (filename: string) => Promise<string>;
probeAudioDurationSeconds?: (buffer: Buffer, filename: string) => Promise<number | null>;
logWarn?: (message: string, ...args: unknown[]) => void;
}
interface ProbeAudioDurationDeps {
execFile?: typeof nodeExecFile;
mkdtempSync?: typeof fs.mkdtempSync;
writeFileSync?: typeof fs.writeFileSync;
rmSync?: typeof fs.rmSync;
}
export function extractSoundFilenames(value: string): string[] {
const matches = value.matchAll(/\[sound:([^\]]+)\]/gi);
return Array.from(matches, (match) => match[1]?.trim() || '').filter((value) => value.length > 0);
}
function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'media'>): boolean {
return (
config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false
);
}
export async function probeAudioDurationSeconds(
buffer: Buffer,
filename: string,
deps: ProbeAudioDurationDeps = {},
): Promise<number | null> {
const execFile = deps.execFile ?? nodeExecFile;
const mkdtempSync = deps.mkdtempSync ?? fs.mkdtempSync;
const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
const rmSync = deps.rmSync ?? fs.rmSync;
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-audio-probe-'));
const ext = path.extname(filename) || '.bin';
const tempPath = path.join(tempDir, `probe${ext}`);
writeFileSync(tempPath, buffer);
return new Promise((resolve) => {
execFile(
'ffprobe',
[
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
tempPath,
],
(error, stdout) => {
try {
if (error) {
resolve(null);
return;
}
const durationSeconds = Number.parseFloat((stdout || '').trim());
resolve(Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : null);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
}
export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteInfoLike>({
config,
noteInfo,
resolveConfiguredFieldName,
retrieveMediaFileBase64,
probeAudioDurationSeconds: probeDuration = probeAudioDurationSeconds,
logWarn,
}: ResolveAnimatedImageLeadInSecondsArgs<TNoteInfo>): Promise<number> {
if (!shouldSyncAnimatedImageToWordAudio(config)) {
return 0;
}
const wordAudioFieldName = resolveConfiguredFieldName(
noteInfo,
config.fields?.audio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio,
);
if (!wordAudioFieldName) {
return 0;
}
const wordAudioValue = noteInfo.fields[wordAudioFieldName]?.value || '';
const filenames = extractSoundFilenames(wordAudioValue);
if (filenames.length === 0) {
return 0;
}
let totalLeadInSeconds = 0;
for (const filename of filenames) {
const encoded = await retrieveMediaFileBase64(filename);
if (!encoded) {
logWarn?.('Animated image sync skipped: failed to retrieve word audio', filename);
return 0;
}
const durationSeconds = await probeDuration(Buffer.from(encoded, 'base64'), filename);
if (!(typeof durationSeconds === 'number' && Number.isFinite(durationSeconds))) {
logWarn?.('Animated image sync skipped: failed to probe word audio duration', filename);
return 0;
}
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds;
}

View File

@@ -1,4 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import test from 'node:test'; import test from 'node:test';
import { AnkiConnectProxyServer } from './anki-connect-proxy'; import { AnkiConnectProxyServer } from './anki-connect-proxy';
@@ -322,6 +324,83 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
assert.deepEqual(recordedCards, [1]); assert.deepEqual(recordedCards, [1]);
}); });
test('proxy returns addNote response without waiting for background enrichment', async () => {
const processed: number[] = [];
let releaseProcessing: (() => void) | undefined;
const processingGate = new Promise<void>((resolve) => {
releaseProcessing = resolve;
});
const upstream = http.createServer((req, res) => {
assert.equal(req.method, 'POST');
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ result: 42, error: null }));
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const upstreamAddress = upstream.address();
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
const upstreamPort = upstreamAddress.port;
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async (noteId) => {
processed.push(noteId);
await processingGate;
},
logInfo: () => undefined,
logWarn: () => undefined,
logError: () => undefined,
});
try {
proxy.start({
host: '127.0.0.1',
port: 0,
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
});
const proxyServer = (
proxy as unknown as {
server: http.Server | null;
}
).server;
assert.ok(proxyServer);
if (!proxyServer.listening) {
await once(proxyServer, 'listening');
}
const proxyAddress = proxyServer.address();
assert.ok(proxyAddress && typeof proxyAddress === 'object');
const proxyPort = proxyAddress.port;
const response = await Promise.race([
fetch(`http://127.0.0.1:${proxyPort}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ action: 'addNote', version: 6, params: {} }),
}),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 500);
}),
]);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { result: 42, error: null });
await waitForCondition(() => processed.length === 1);
assert.deepEqual(processed, [42]);
} finally {
if (releaseProcessing) {
releaseProcessing();
}
proxy.stop();
upstream.close();
await once(upstream, 'close');
}
});
test('proxy detects self-referential loop configuration', () => { test('proxy detects self-referential loop configuration', () => {
const proxy = new AnkiConnectProxyServer({ const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true, shouldAutoUpdateNewCards: () => true,

View File

@@ -0,0 +1,285 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CardCreationService } from './card-creation';
import type { AnkiConnectConfig } from '../types';
test('CardCreationService counts locally created sentence cards', async () => {
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => [],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: () => undefined,
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
recordCardsMinedCallback: (count, noteIds) => {
minedCards.push({ count, noteIds });
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(minedCards, [{ count: 1, noteIds: [42] }]);
});
test('CardCreationService keeps updating after trackLastAddedNoteId throws', async () => {
const calls = {
notesInfo: 0,
updateNoteFields: 0,
};
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => {
calls.notesInfo += 1;
return [
{
noteId: 42,
fields: {
Sentence: { value: 'existing' },
},
},
];
},
updateNoteFields: async () => {
calls.updateNoteFields += 1;
},
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: (updatedFields) => {
updatedFields.CardType = 'sentence';
},
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => {
throw new Error('track failed');
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.equal(calls.notesInfo, 1);
assert.equal(calls.updateNoteFields, 1);
});
test('CardCreationService keeps updating after recordCardsMinedCallback throws', async () => {
const calls = {
notesInfo: 0,
updateNoteFields: 0,
};
const service = new CardCreationService({
getConfig: () =>
({
deck: 'Mining',
fields: {
sentence: 'Sentence',
audio: 'SentenceAudio',
},
media: {
generateAudio: false,
generateImage: false,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never,
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 1,
currentSubEnd: 2,
currentTimePos: 1.5,
currentAudioStreamIndex: 0,
}) as never,
client: {
addNote: async () => 42,
addTags: async () => undefined,
notesInfo: async () => {
calls.notesInfo += 1;
return [
{
noteId: 42,
fields: {
Sentence: { value: 'existing' },
},
},
];
},
updateNoteFields: async () => {
calls.updateNoteFields += 1;
},
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
showOsdNotification: () => undefined,
showUpdateResult: () => undefined,
showStatusNotification: () => undefined,
showNotification: async () => undefined,
beginUpdateProgress: () => undefined,
endUpdateProgress: () => undefined,
withUpdateProgress: async (_message, action) => action(),
resolveConfiguredFieldName: () => null,
resolveNoteFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
extractFields: () => ({}),
processSentence: (sentence) => sentence,
setCardTypeFields: (updatedFields) => {
updatedFields.CardType = 'sentence';
},
mergeFieldValue: (_existing, newValue) => newValue,
formatMiscInfoPattern: () => '',
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
recordCardsMinedCallback: () => {
throw new Error('record failed');
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.equal(calls.notesInfo, 1);
assert.equal(calls.updateNoteFields, 1);
});

View File

@@ -30,6 +30,7 @@ interface CardCreationClient {
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>; updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>; storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>; findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
retrieveMediaFile(filename: string): Promise<string>;
} }
interface CardCreationMediaGenerator { interface CardCreationMediaGenerator {
@@ -60,6 +61,7 @@ interface CardCreationMediaGenerator {
maxWidth?: number; maxWidth?: number;
maxHeight?: number; maxHeight?: number;
crf?: number; crf?: number;
leadingStillDuration?: number;
}, },
): Promise<Buffer | null>; ): Promise<Buffer | null>;
} }
@@ -73,6 +75,7 @@ interface CardCreationDeps {
client: CardCreationClient; client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator; mediaGenerator: CardCreationMediaGenerator;
showOsdNotification: (text: string) => void; showOsdNotification: (text: string) => void;
showUpdateResult: (message: string, success: boolean) => void;
showStatusNotification: (message: string) => void; showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>; showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
beginUpdateProgress: (initialMessage: string) => void; beginUpdateProgress: (initialMessage: string) => void;
@@ -83,6 +86,7 @@ interface CardCreationDeps {
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
) => string | null; ) => string | null;
resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null; resolveNoteFieldName: (noteInfo: CardCreationNoteInfo, preferredName?: string) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: CardCreationNoteInfo) => Promise<number>;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>; extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string; processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
setCardTypeFields: ( setCardTypeFields: (
@@ -106,6 +110,7 @@ interface CardCreationDeps {
isUpdateInProgress: () => boolean; isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void; setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void; trackLastAddedNoteId?: (noteId: number) => void;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
} }
export class CardCreationService { export class CardCreationService {
@@ -258,11 +263,13 @@ export class CardCreationService {
if (this.deps.getConfig().media?.generateImage) { if (this.deps.getConfig().media?.generateImage) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer( const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath, mpvClient.currentVideoPath,
rangeStart, rangeStart,
rangeEnd, rangeEnd,
animatedLeadInSeconds,
); );
if (imageBuffer) { if (imageBuffer) {
@@ -414,11 +421,13 @@ export class CardCreationService {
if (this.deps.getConfig().media?.generateImage) { if (this.deps.getConfig().media?.generateImage) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename(); const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer( const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath, mpvClient.currentVideoPath,
startTime, startTime,
endTime, endTime,
animatedLeadInSeconds,
); );
const imageField = this.deps.getConfig().fields?.image; const imageField = this.deps.getConfig().fields?.image;
@@ -542,13 +551,24 @@ export class CardCreationService {
this.getConfiguredAnkiTags(), this.getConfiguredAnkiTags(),
); );
log.info('Created sentence card:', noteId); log.info('Created sentence card:', noteId);
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) { } catch (error) {
log.error('Failed to create sentence card:', (error as Error).message); log.error('Failed to create sentence card:', (error as Error).message);
this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false; return false;
} }
try {
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {
log.warn('Failed to track last added note:', (error as Error).message);
}
try {
this.deps.recordCardsMinedCallback?.(1, [noteId]);
} catch (error) {
log.warn('Failed to record mined card:', (error as Error).message);
}
try { try {
const noteInfoResult = await this.deps.client.notesInfo([noteId]); const noteInfoResult = await this.deps.client.notesInfo([noteId]);
const noteInfos = noteInfoResult as CardCreationNoteInfo[]; const noteInfos = noteInfoResult as CardCreationNoteInfo[];
@@ -642,7 +662,7 @@ export class CardCreationService {
}); });
} catch (error) { } catch (error) {
log.error('Error creating sentence card:', (error as Error).message); log.error('Error creating sentence card:', (error as Error).message);
this.deps.showOsdNotification(`Sentence card failed: ${(error as Error).message}`); this.deps.showUpdateResult(`Sentence card failed: ${(error as Error).message}`, false);
return false; return false;
} }
} }
@@ -679,6 +699,7 @@ export class CardCreationService {
videoPath: string, videoPath: string,
startTime: number, startTime: number,
endTime: number, endTime: number,
animatedLeadInSeconds = 0,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient(); const mpvClient = this.deps.getMpvClient();
if (!mpvClient) { if (!mpvClient) {
@@ -707,6 +728,7 @@ export class CardCreationService {
maxWidth: this.deps.getConfig().media?.animatedMaxWidth, maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
maxHeight: this.deps.getConfig().media?.animatedMaxHeight, maxHeight: this.deps.getConfig().media?.animatedMaxHeight,
crf: this.deps.getConfig().media?.animatedCrf, crf: this.deps.getConfig().media?.animatedCrf,
leadingStillDuration: animatedLeadInSeconds,
}, },
); );
} }

View File

@@ -28,7 +28,7 @@ interface FieldGroupingMergeDeps {
) => string | null; ) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>; extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string; processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
generateMediaForMerge: () => Promise<FieldGroupingMergeMedia>; generateMediaForMerge: (noteInfo: FieldGroupingMergeNoteInfo) => Promise<FieldGroupingMergeMedia>;
warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void; warnFieldParseOnce: (fieldName: string, reason: string, detail?: string) => void;
} }
@@ -132,7 +132,7 @@ export class FieldGroupingMergeCollaborator {
} }
if (includeGeneratedMedia) { if (includeGeneratedMedia) {
const media = await this.deps.generateMediaForMerge(); const media = await this.deps.generateMediaForMerge(keepNoteInfo);
if (media.audioField && media.audioValue && !sourceFields[media.audioField]) { if (media.audioField && media.audioValue && !sourceFields[media.audioField]) {
sourceFields[media.audioField] = media.audioValue; sourceFields[media.audioField] = media.audioValue;
} }

View File

@@ -0,0 +1,535 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AnkiConnectConfig } from '../types';
import { KnownWordCacheManager } from './known-word-cache';
async function waitForCondition(
condition: () => boolean,
timeoutMs = 500,
intervalMs = 10,
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (condition()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error('Timed out waiting for condition');
}
function createKnownWordCacheHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: {
findNotes: number;
notesInfo: number;
};
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
findNotesByQuery: Map<string, number[]>;
};
cleanup: () => void;
} {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
const calls = {
findNotes: 0,
notesInfo: 0,
};
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as Array<{ noteId: number; fields: Record<string, { value: string }> }>,
findNotesByQuery: new Map<string, number[]>(),
};
const manager = new KnownWordCacheManager({
client: {
findNotes: async (query) => {
calls.findNotes += 1;
if (clientState.findNotesByQuery.has(query)) {
return clientState.findNotesByQuery.get(query) ?? [];
}
return clientState.findNotesResult;
},
notesInfo: async (noteIds) => {
calls.notesInfo += 1;
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
});
return {
manager,
calls,
statePath,
clientState,
cleanup: () => {
fs.rmSync(stateDir, { recursive: true, force: true });
},
};
}
test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without immediate refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
refreshMinutes: 60,
},
};
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now(),
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":""}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
manager.startLifecycle();
await new Promise((resolve) => setTimeout(resolve, 25));
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(calls.findNotes, 0);
assert.equal(calls.notesInfo, 0);
} finally {
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager startLifecycle immediately refreshes stale persisted cache', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
refreshMinutes: 1,
},
};
const { manager, calls, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: Date.now() - 61_000,
scope: '{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫'],
notes: {
'1': ['猫'],
},
}),
'utf-8',
);
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '犬' },
},
},
];
manager.startLifecycle();
await waitForCondition(() => calls.findNotes === 1 && calls.notesInfo === 1);
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), true);
} finally {
manager.stopLifecycle();
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when fields.word changes', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Word: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
config.fields = {
...config.fields,
word: 'Expression',
};
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, statePath, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
fs.writeFileSync(
statePath,
JSON.stringify({
version: 2,
refreshedAtMs: 1,
scope: '{"refreshMinutes":1440,"scope":"is:note","fieldsWord":"Word"}',
words: ['猫', '犬'],
notes: {
'1': ['猫'],
'2': ['犬'],
},
}),
'utf-8',
);
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '鳥' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(manager.isKnownWord('犬'), false);
assert.equal(manager.isKnownWord('鳥'), true);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
words: string[];
notes?: Record<string, string[]>;
};
assert.equal(persisted.version, 2);
assert.deepEqual(persisted.words.sort(), ['鳥']);
assert.deepEqual(persisted.notes, {
'1': ['鳥'],
});
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips malformed note info without fields', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1, 2];
clientState.notesInfoResult = [
{
noteId: 1,
fields: undefined as unknown as Record<string, { value: string }>,
},
{
noteId: 2,
fields: {
Word: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('犬'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager preserves cache state key captured before refresh work', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
refreshMinutes: 1,
},
};
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-cache-key-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
let notesInfoStarted = false;
let releaseNotesInfo!: () => void;
const notesInfoGate = new Promise<void>((resolve) => {
releaseNotesInfo = resolve;
});
const manager = new KnownWordCacheManager({
client: {
findNotes: async () => [1],
notesInfo: async () => {
notesInfoStarted = true;
await notesInfoGate;
return [
{
noteId: 1,
fields: {
Word: { value: '猫' },
},
},
];
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
});
try {
const refreshPromise = manager.refresh(true);
await waitForCondition(() => notesInfoStarted);
config.fields = {
...config.fields,
word: 'Expression',
};
releaseNotesInfo();
await refreshPromise;
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
scope: string;
words: string[];
};
assert.equal(
persisted.scope,
'{"refreshMinutes":1,"scope":"is:note","fieldsWord":"Word"}',
);
assert.deepEqual(persisted.words, ['猫']);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
test('KnownWordCacheManager does not borrow fields from other decks during refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
decks: {
Mining: [],
Reading: ['AltWord'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Reading"', []);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
AltWord: { value: '猫' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager invalidates persisted cache when per-deck fields change', () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Word'],
},
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Word: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
config.knownWords = {
...config.knownWords,
decks: {
Mining: ['Expression'],
},
};
(
manager as unknown as {
loadKnownWordCacheState: () => void;
}
).loadKnownWordCacheState();
assert.equal(manager.isKnownWord('猫'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager preserves deck-specific field mappings during refresh', async () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Expression'],
Reading: ['Word'],
},
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Reading"', [2]);
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Expression: { value: '猫' },
Word: { value: 'should-not-count' },
},
},
{
noteId: 2,
fields: {
Word: { value: '犬' },
Expression: { value: 'also-ignored' },
},
},
];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('犬'), true);
assert.equal(manager.isKnownWord('should-not-count'), false);
assert.equal(manager.isKnownWord('also-ignored'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager uses the current deck fields for immediate append', () => {
const config: AnkiConnectConfig = {
deck: 'Mining',
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
decks: {
Mining: ['Expression'],
Reading: ['Word'],
},
},
};
const { manager, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '猫' },
Word: { value: 'should-not-count' },
},
});
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('should-not-count'), false);
} finally {
cleanup();
}
});
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
const config: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
addMinedWordsImmediately: false,
},
};
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 1,
fields: {
Expression: { value: '猫' },
},
});
assert.equal(manager.isKnownWord('猫'), false);
assert.equal(fs.existsSync(statePath), false);
} finally {
cleanup();
}
});

View File

@@ -8,18 +8,79 @@ import { createLogger } from '../logger';
const log = createLogger('anki').child('integration.known-word-cache'); const log = createLogger('anki').child('integration.known-word-cache');
function trimToNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function getKnownWordCacheRefreshIntervalMinutes(config: AnkiConnectConfig): number {
const refreshMinutes = config.knownWords?.refreshMinutes;
return typeof refreshMinutes === 'number' && Number.isFinite(refreshMinutes) && refreshMinutes > 0
? refreshMinutes
: DEFAULT_ANKI_CONNECT_CONFIG.knownWords.refreshMinutes;
}
export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string {
const configuredDecks = config.knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const normalizedDecks = Object.entries(configuredDecks)
.map(([deckName, fields]) => {
const name = trimToNonEmptyString(deckName);
if (!name) return null;
const normalizedFields = Array.isArray(fields)
? [
...new Set(
fields
.map(String)
.map(trimToNonEmptyString)
.filter((field): field is string => Boolean(field)),
),
].sort()
: [];
return [name, normalizedFields];
})
.filter((entry): entry is [string, string[]] => entry !== null)
.sort(([a], [b]) => a.localeCompare(b));
if (normalizedDecks.length > 0) {
return `decks:${JSON.stringify(normalizedDecks)}`;
}
}
const configuredDeck = trimToNonEmptyString(config.deck);
return configuredDeck ? `deck:${configuredDeck}` : 'is:note';
}
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return JSON.stringify({
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
scope: getKnownWordCacheScopeForConfig(config),
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
});
}
export interface KnownWordCacheNoteInfo { export interface KnownWordCacheNoteInfo {
noteId: number; noteId: number;
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
} }
interface KnownWordCacheState { interface KnownWordCacheStateV1 {
readonly version: 1; readonly version: 1;
readonly refreshedAtMs: number; readonly refreshedAtMs: number;
readonly scope: string; readonly scope: string;
readonly words: string[]; readonly words: string[];
} }
interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
interface KnownWordCacheClient { interface KnownWordCacheClient {
findNotes: ( findNotes: (
query: string, query: string,
@@ -37,11 +98,19 @@ interface KnownWordCacheDeps {
showStatusNotification: (message: string) => void; showStatusNotification: (message: string) => void;
} }
type KnownWordQueryScope = {
query: string;
fields: string[];
};
export class KnownWordCacheManager { export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0; private knownWordsLastRefreshedAtMs = 0;
private knownWordsScope = ''; private knownWordsStateKey = '';
private knownWords: Set<string> = new Set(); private knownWords: Set<string> = new Set();
private wordReferenceCounts = new Map<string, number>();
private noteWordsById = new Map<number, string[]>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null; private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false; private isRefreshingKnownWords = false;
private readonly statePath: string; private readonly statePath: string;
@@ -73,7 +142,7 @@ export class KnownWordCacheManager {
} }
const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000; const refreshMinutes = this.getKnownWordRefreshIntervalMs() / 60_000;
const scope = this.getKnownWordCacheScope(); const scope = getKnownWordCacheScopeForConfig(this.deps.getConfig());
log.info( log.info(
'Known-word cache lifecycle enabled', 'Known-word cache lifecycle enabled',
`scope=${scope}`, `scope=${scope}`,
@@ -82,14 +151,14 @@ export class KnownWordCacheManager {
); );
this.loadKnownWordCacheState(); this.loadKnownWordCacheState();
void this.refreshKnownWords(); this.scheduleKnownWordRefreshLifecycle();
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
} }
stopLifecycle(): void { stopLifecycle(): void {
if (this.knownWordsRefreshTimeout) {
clearTimeout(this.knownWordsRefreshTimeout);
this.knownWordsRefreshTimeout = null;
}
if (this.knownWordsRefreshTimer) { if (this.knownWordsRefreshTimer) {
clearInterval(this.knownWordsRefreshTimer); clearInterval(this.knownWordsRefreshTimer);
this.knownWordsRefreshTimer = null; this.knownWordsRefreshTimer = null;
@@ -97,45 +166,44 @@ export class KnownWordCacheManager {
} }
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void { appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
if (!this.isKnownWordCacheEnabled()) { if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
return; return;
} }
const currentScope = this.getKnownWordCacheScope(); const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsScope && this.knownWordsScope !== currentScope) { if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
this.clearKnownWordCacheState(); this.clearKnownWordCacheState();
} }
if (!this.knownWordsScope) { if (!this.knownWordsStateKey) {
this.knownWordsScope = currentScope; this.knownWordsStateKey = currentStateKey;
} }
let addedCount = 0; const preferredFields = this.getImmediateAppendFields();
for (const rawWord of this.extractKnownWordsFromNoteInfo(noteInfo)) { if (!preferredFields) {
const normalized = this.normalizeKnownWordForLookup(rawWord); return;
if (!normalized || this.knownWords.has(normalized)) {
continue;
}
this.knownWords.add(normalized);
addedCount += 1;
} }
if (addedCount > 0) { const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
if (this.knownWordsLastRefreshedAtMs <= 0) { const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
this.knownWordsLastRefreshedAtMs = Date.now(); if (!changed) {
} return;
this.persistKnownWordCacheState();
log.info(
'Known-word cache updated in-session',
`added=${addedCount}`,
`scope=${currentScope}`,
);
} }
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
this.persistKnownWordCacheState();
log.info(
'Known-word cache updated in-session',
`noteId=${noteInfo.noteId}`,
`wordCount=${nextWords.length}`,
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
);
} }
clearKnownWordCacheState(): void { clearKnownWordCacheState(): void {
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
try { try {
if (fs.existsSync(this.statePath)) { if (fs.existsSync(this.statePath)) {
fs.unlinkSync(this.statePath); fs.unlinkSync(this.statePath);
@@ -159,41 +227,43 @@ export class KnownWordCacheManager {
return; return;
} }
const frozenStateKey = this.getKnownWordCacheStateKey();
this.isRefreshingKnownWords = true; this.isRefreshingKnownWords = true;
try { try {
const query = this.buildKnownWordsQuery(); const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
log.debug('Refreshing known-word cache', `query=${query}`); const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
const noteIds = (await this.deps.client.findNotes(query, {
maxRetries: 0,
})) as number[];
const nextKnownWords = new Set<string>(); if (this.noteWordsById.size === 0) {
if (noteIds.length > 0) { await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
const chunkSize = 50; } else {
for (let i = 0; i < noteIds.length; i += chunkSize) { const currentNoteIdSet = new Set(currentNoteIds);
const chunk = noteIds.slice(i, i + chunkSize); for (const noteId of Array.from(this.noteWordsById.keys())) {
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[]; if (!currentNoteIdSet.has(noteId)) {
const notesInfo = notesInfoResult as KnownWordCacheNoteInfo[]; this.removeNoteSnapshot(noteId);
}
}
for (const noteInfo of notesInfo) { if (currentNoteIds.length > 0) {
for (const word of this.extractKnownWordsFromNoteInfo(noteInfo)) { const noteInfos = await this.fetchKnownWordNotesInfo(currentNoteIds);
const normalized = this.normalizeKnownWordForLookup(word); for (const noteInfo of noteInfos) {
if (normalized) { this.replaceNoteSnapshot(
nextKnownWords.add(normalized); noteInfo.noteId,
} this.extractNormalizedKnownWordsFromNoteInfo(
} noteInfo,
noteFieldsById.get(noteInfo.noteId),
),
);
} }
} }
} }
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = Date.now(); this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsScope = this.getKnownWordCacheScope(); this.knownWordsStateKey = frozenStateKey;
this.persistKnownWordCacheState(); this.persistKnownWordCacheState();
log.info( log.info(
'Known-word cache refreshed', 'Known-word cache refreshed',
`noteCount=${noteIds.length}`, `noteCount=${currentNoteIds.length}`,
`wordCount=${nextKnownWords.size}`, `wordCount=${this.knownWords.size}`,
); );
} catch (error) { } catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message); log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -207,13 +277,17 @@ export class KnownWordCacheManager {
return this.deps.getConfig().knownWords?.highlightEnabled === true; return this.deps.getConfig().knownWords?.highlightEnabled === true;
} }
private shouldAddMinedWordsImmediately(): boolean {
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
}
private getKnownWordRefreshIntervalMs(): number { private getKnownWordRefreshIntervalMs(): number {
const minutes = this.deps.getConfig().knownWords?.refreshMinutes; return getKnownWordCacheRefreshIntervalMinutes(this.deps.getConfig()) * 60_000;
const safeMinutes = }
typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0
? minutes private getDefaultKnownWordFields(): string[] {
: DEFAULT_ANKI_CONNECT_CONFIG.knownWords.refreshMinutes; const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return safeMinutes * 60_000; return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
} }
private getKnownWordDecks(): string[] { private getKnownWordDecks(): string[] {
@@ -229,20 +303,69 @@ export class KnownWordCacheManager {
} }
private getConfiguredFields(): string[] { private getConfiguredFields(): string[] {
return this.getDefaultKnownWordFields();
}
private getImmediateAppendFields(): string[] | null {
const configuredDecks = this.deps.getConfig().knownWords?.decks; const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) { if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const allFields = new Set<string>(); const trimmedDeckEntries = Object.entries(configuredDecks)
for (const fields of Object.values(configuredDecks)) { .map(([deckName, fields]) => [deckName.trim(), fields] as const)
if (Array.isArray(fields)) { .filter(([deckName]) => deckName.length > 0);
for (const f of fields) {
if (typeof f === 'string' && f.trim()) allFields.add(f.trim()); const currentDeck = this.deps.getConfig().deck?.trim();
} const selectedDeckEntry =
currentDeck !== undefined && currentDeck.length > 0
? trimmedDeckEntries.find(([deckName]) => deckName === currentDeck) ?? null
: trimmedDeckEntries.length === 1
? trimmedDeckEntries[0] ?? null
: null;
if (!selectedDeckEntry) {
return null;
}
const deckFields = selectedDeckEntry[1];
if (Array.isArray(deckFields)) {
const normalizedFields = [
...new Set(
deckFields.map(String).map((field) => field.trim()).filter((field) => field.length > 0),
),
];
if (normalizedFields.length > 0) {
return normalizedFields;
} }
} }
if (allFields.size > 0) return [...allFields];
return this.getDefaultKnownWordFields();
} }
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])]; return this.getConfiguredFields();
}
private getKnownWordQueryScopes(): KnownWordQueryScope[] {
const configuredDecks = this.deps.getConfig().knownWords?.decks;
if (configuredDecks && typeof configuredDecks === 'object' && !Array.isArray(configuredDecks)) {
const scopes: KnownWordQueryScope[] = [];
for (const [deckName, fields] of Object.entries(configuredDecks)) {
const trimmedDeckName = deckName.trim();
if (!trimmedDeckName) {
continue;
}
const normalizedFields = Array.isArray(fields)
? [...new Set(fields.map(String).map((field) => field.trim()).filter(Boolean))]
: [];
scopes.push({
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
});
}
if (scopes.length > 0) {
return scopes;
}
}
return [{ query: this.buildKnownWordsQuery(), fields: this.getDefaultKnownWordFields() }];
} }
private buildKnownWordsQuery(): string { private buildKnownWordsQuery(): string {
@@ -259,19 +382,15 @@ export class KnownWordCacheManager {
return `(${deckQueries.join(' OR ')})`; return `(${deckQueries.join(' OR ')})`;
} }
private getKnownWordCacheScope(): string { private getKnownWordCacheStateKey(): string {
const decks = this.getKnownWordDecks(); return getKnownWordCacheLifecycleConfig(this.deps.getConfig());
if (decks.length === 0) {
return 'is:note';
}
return `decks:${JSON.stringify(decks)}`;
} }
private isKnownWordCacheStale(): boolean { private isKnownWordCacheStale(): boolean {
if (!this.isKnownWordCacheEnabled()) { if (!this.isKnownWordCacheEnabled()) {
return true; return true;
} }
if (this.knownWordsScope !== this.getKnownWordCacheScope()) { if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
return true; return true;
} }
if (this.knownWordsLastRefreshedAtMs <= 0) { if (this.knownWordsLastRefreshedAtMs <= 0) {
@@ -280,64 +399,231 @@ export class KnownWordCacheManager {
return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs(); return Date.now() - this.knownWordsLastRefreshedAtMs >= this.getKnownWordRefreshIntervalMs();
} }
private async fetchKnownWordNoteFieldsById(): Promise<Map<number, string[]>> {
const scopes = this.getKnownWordQueryScopes();
const noteFieldsById = new Map<number, string[]>();
log.debug('Refreshing known-word cache', `queries=${scopes.map((scope) => scope.query).join(' | ')}`);
for (const scope of scopes) {
const noteIds = (await this.deps.client.findNotes(scope.query, {
maxRetries: 0,
})) as number[];
for (const noteId of noteIds) {
if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const existingFields = noteFieldsById.get(noteId) ?? [];
noteFieldsById.set(
noteId,
[...new Set([...existingFields, ...scope.fields])],
);
}
}
return noteFieldsById;
}
private scheduleKnownWordRefreshLifecycle(): void {
const refreshIntervalMs = this.getKnownWordRefreshIntervalMs();
const scheduleInterval = () => {
this.knownWordsRefreshTimer = setInterval(() => {
void this.refreshKnownWords();
}, refreshIntervalMs);
};
const initialDelayMs = this.getMsUntilNextRefresh();
this.knownWordsRefreshTimeout = setTimeout(() => {
this.knownWordsRefreshTimeout = null;
void this.refreshKnownWords();
scheduleInterval();
}, initialDelayMs);
}
private getMsUntilNextRefresh(): number {
if (this.knownWordsStateKey !== this.getKnownWordCacheStateKey()) {
return 0;
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
return 0;
}
const remainingMs =
this.getKnownWordRefreshIntervalMs() - (Date.now() - this.knownWordsLastRefreshedAtMs);
return Math.max(0, remainingMs);
}
private async rebuildFromCurrentNotes(
noteIds: number[],
noteFieldsById: Map<number, string[]>,
): Promise<void> {
this.clearInMemoryState();
if (noteIds.length === 0) {
return;
}
const noteInfos = await this.fetchKnownWordNotesInfo(noteIds);
for (const noteInfo of noteInfos) {
this.replaceNoteSnapshot(
noteInfo.noteId,
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
);
}
}
private async fetchKnownWordNotesInfo(noteIds: number[]): Promise<KnownWordCacheNoteInfo[]> {
const noteInfos: KnownWordCacheNoteInfo[] = [];
const chunkSize = 50;
for (let i = 0; i < noteIds.length; i += chunkSize) {
const chunk = noteIds.slice(i, i + chunkSize);
const notesInfoResult = (await this.deps.client.notesInfo(chunk)) as unknown[];
const chunkInfos = notesInfoResult as KnownWordCacheNoteInfo[];
for (const noteInfo of chunkInfos) {
if (
!noteInfo ||
!Number.isInteger(noteInfo.noteId) ||
noteInfo.noteId <= 0 ||
typeof noteInfo.fields !== 'object' ||
noteInfo.fields === null ||
Array.isArray(noteInfo.fields)
) {
continue;
}
noteInfos.push(noteInfo);
}
}
return noteInfos;
}
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
const normalizedWords = normalizeKnownWordList(nextWords);
const previousWords = this.noteWordsById.get(noteId) ?? [];
if (knownWordListsEqual(previousWords, normalizedWords)) {
return false;
}
this.removeWordsFromCounts(previousWords);
if (normalizedWords.length > 0) {
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
} else {
this.noteWordsById.delete(noteId);
}
return true;
}
private removeNoteSnapshot(noteId: number): void {
const previousWords = this.noteWordsById.get(noteId);
if (!previousWords) {
return;
}
this.noteWordsById.delete(noteId);
this.removeWordsFromCounts(previousWords);
}
private addWordsToCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
this.wordReferenceCounts.set(word, nextCount);
this.knownWords.add(word);
}
}
private removeWordsFromCounts(words: string[]): void {
for (const word of words) {
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
if (nextCount > 0) {
this.wordReferenceCounts.set(word, nextCount);
} else {
this.wordReferenceCounts.delete(word);
this.knownWords.delete(word);
}
}
}
private clearInMemoryState(): void {
this.knownWords = new Set();
this.wordReferenceCounts = new Map();
this.noteWordsById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
private loadKnownWordCacheState(): void { private loadKnownWordCacheState(): void {
try { try {
if (!fs.existsSync(this.statePath)) { if (!fs.existsSync(this.statePath)) {
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
return; return;
} }
const raw = fs.readFileSync(this.statePath, 'utf-8'); const raw = fs.readFileSync(this.statePath, 'utf-8');
if (!raw.trim()) { if (!raw.trim()) {
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
return; return;
} }
const parsed = JSON.parse(raw) as unknown; const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) { if (!this.isKnownWordCacheStateValid(parsed)) {
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
return; return;
} }
if (parsed.scope !== this.getKnownWordCacheScope()) { if (parsed.scope !== this.getKnownWordCacheStateKey()) {
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
return; return;
} }
const nextKnownWords = new Set<string>(); this.clearInMemoryState();
for (const value of parsed.words) { if (parsed.version === 2) {
const normalized = this.normalizeKnownWordForLookup(value); for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
if (normalized) { const noteId = Number.parseInt(noteIdKey, 10);
nextKnownWords.add(normalized); if (!Number.isInteger(noteId) || noteId <= 0) {
continue;
}
const normalizedWords = normalizeKnownWordList(words);
if (normalizedWords.length === 0) {
continue;
}
this.noteWordsById.set(noteId, normalizedWords);
this.addWordsToCounts(normalizedWords);
}
} else {
for (const value of parsed.words) {
const normalized = this.normalizeKnownWordForLookup(value);
if (!normalized) {
continue;
}
this.knownWords.add(normalized);
this.wordReferenceCounts.set(normalized, 1);
} }
} }
this.knownWords = nextKnownWords;
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs; this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsScope = parsed.scope; this.knownWordsStateKey = parsed.scope;
} catch (error) { } catch (error) {
log.warn('Failed to load known-word cache state:', (error as Error).message); log.warn('Failed to load known-word cache state:', (error as Error).message);
this.knownWords = new Set(); this.clearInMemoryState();
this.knownWordsLastRefreshedAtMs = 0; this.knownWordsStateKey = this.getKnownWordCacheStateKey();
this.knownWordsScope = this.getKnownWordCacheScope();
} }
} }
private persistKnownWordCacheState(): void { private persistKnownWordCacheState(): void {
try { try {
const state: KnownWordCacheState = { const notes: Record<string, string[]> = {};
version: 1, for (const [noteId, words] of this.noteWordsById.entries()) {
if (words.length > 0) {
notes[String(noteId)] = words;
}
}
const state: KnownWordCacheStateV2 = {
version: 2,
refreshedAtMs: this.knownWordsLastRefreshedAtMs, refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsScope, scope: this.knownWordsStateKey,
words: Array.from(this.knownWords), words: Array.from(this.knownWords),
notes,
}; };
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8'); fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) { } catch (error) {
@@ -347,33 +633,52 @@ export class KnownWordCacheManager {
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState { private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const candidate = value as Partial<KnownWordCacheState>; const candidate = value as Record<string, unknown>;
if (candidate.version !== 1) return false; if (candidate.version !== 1 && candidate.version !== 2) return false;
if (typeof candidate.refreshedAtMs !== 'number') return false; if (typeof candidate.refreshedAtMs !== 'number') return false;
if (typeof candidate.scope !== 'string') return false; if (typeof candidate.scope !== 'string') return false;
if (!Array.isArray(candidate.words)) return false; if (!Array.isArray(candidate.words)) return false;
if (!candidate.words.every((entry) => typeof entry === 'string')) { if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
return false; return false;
} }
if (candidate.version === 2) {
if (
typeof candidate.notes !== 'object' ||
candidate.notes === null ||
Array.isArray(candidate.notes)
) {
return false;
}
if (
!Object.values(candidate.notes as Record<string, unknown>).every(
(entry) =>
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
)
) {
return false;
}
}
return true; return true;
} }
private extractKnownWordsFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): string[] { private extractNormalizedKnownWordsFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
): string[] {
const words: string[] = []; const words: string[] = [];
const configuredFields = this.getConfiguredFields(); for (const preferredField of preferredFields) {
for (const preferredField of configuredFields) {
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField); const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
if (!fieldName) continue; if (!fieldName) continue;
const raw = noteInfo.fields[fieldName]?.value; const raw = noteInfo.fields[fieldName]?.value;
if (!raw) continue; if (!raw) continue;
const extracted = this.normalizeRawKnownWordValue(raw); const normalized = this.normalizeKnownWordForLookup(raw);
if (extracted) { if (normalized) {
words.push(extracted); words.push(normalized);
} }
} }
return words; return normalizeKnownWordList(words);
} }
private normalizeRawKnownWordValue(value: string): string { private normalizeRawKnownWordValue(value: string): string {
@@ -388,6 +693,22 @@ export class KnownWordCacheManager {
} }
} }
function normalizeKnownWordList(words: string[]): string[] {
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
}
function knownWordListsEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null { function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
const exact = availableFieldNames.find((name) => name === preferredName); const exact = availableFieldNames.find((name) => name === preferredName);
if (exact) return exact; if (exact) return exact;

View File

@@ -62,6 +62,7 @@ function createWorkflowHarness() {
return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null; return names.find((name) => name.toLowerCase() === preferred.toLowerCase()) ?? null;
}, },
getResolvedSentenceAudioFieldName: () => null, getResolvedSentenceAudioFieldName: () => null,
getAnimatedImageLeadInSeconds: async () => 0,
mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next, mergeFieldValue: (_existing: string, next: string, _overwrite: boolean) => next,
generateAudioFilename: () => 'audio_1.mp3', generateAudioFilename: () => 'audio_1.mp3',
generateAudio: async () => null, generateAudio: async () => null,
@@ -163,3 +164,42 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
assert.deepEqual(callOrder, ['update', 'auto']); assert.deepEqual(callOrder, ['update', 'auto']);
assert.equal(harness.updates.length, 1); assert.equal(harness.updates.length, 1);
}); });
test('NoteUpdateWorkflow passes animated image lead-in when syncing avif to word audio', async () => {
const harness = createWorkflowHarness();
let receivedLeadInSeconds = 0;
harness.deps.client.notesInfo = async () =>
[
{
noteId: 42,
fields: {
Expression: { value: 'taberu' },
ExpressionAudio: { value: '[sound:word.mp3]' },
Sentence: { value: '' },
Picture: { value: '' },
},
},
] satisfies NoteUpdateWorkflowNoteInfo[];
harness.deps.getConfig = () => ({
fields: {
sentence: 'Sentence',
image: 'Picture',
},
media: {
generateImage: true,
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
},
behavior: {},
});
harness.deps.getAnimatedImageLeadInSeconds = async () => 1.25;
harness.deps.generateImage = async (leadInSeconds?: number) => {
receivedLeadInSeconds = leadInSeconds ?? 0;
return Buffer.from('image');
};
await harness.workflow.execute(42);
assert.equal(receivedLeadInSeconds, 1.25);
});

View File

@@ -22,6 +22,8 @@ export interface NoteUpdateWorkflowDeps {
media?: { media?: {
generateAudio?: boolean; generateAudio?: boolean;
generateImage?: boolean; generateImage?: boolean;
imageType?: 'static' | 'avif';
syncAnimatedImageToWordAudio?: boolean;
}; };
behavior?: { behavior?: {
overwriteAudio?: boolean; overwriteAudio?: boolean;
@@ -60,11 +62,12 @@ export interface NoteUpdateWorkflowDeps {
...preferredNames: (string | undefined)[] ...preferredNames: (string | undefined)[]
) => string | null; ) => string | null;
getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null; getResolvedSentenceAudioFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo) => string | null;
getAnimatedImageLeadInSeconds: (noteInfo: NoteUpdateWorkflowNoteInfo) => Promise<number>;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string; mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
generateAudioFilename: () => string; generateAudioFilename: () => string;
generateAudio: () => Promise<Buffer | null>; generateAudio: () => Promise<Buffer | null>;
generateImageFilename: () => string; generateImageFilename: () => string;
generateImage: () => Promise<Buffer | null>; generateImage: (animatedLeadInSeconds?: number) => Promise<Buffer | null>;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
addConfiguredTagsToNote: (noteId: number) => Promise<void>; addConfiguredTagsToNote: (noteId: number) => Promise<void>;
showNotification: (noteId: number, label: string | number) => Promise<void>; showNotification: (noteId: number, label: string | number) => Promise<void>;
@@ -153,8 +156,9 @@ export class NoteUpdateWorkflow {
if (config.media?.generateImage) { if (config.media?.generateImage) {
try { try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.deps.generateImageFilename(); const imageFilename = this.deps.generateImageFilename();
const imageBuffer = await this.deps.generateImage(); const imageBuffer = await this.deps.generateImage(animatedLeadInSeconds);
if (imageBuffer) { if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);

View File

@@ -59,6 +59,10 @@ test('AnkiIntegrationRuntime normalizes url and proxy defaults', () => {
normalized.media?.fallbackDuration, normalized.media?.fallbackDuration,
DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration, DEFAULT_ANKI_CONNECT_CONFIG.media.fallbackDuration,
); );
assert.equal(
normalized.media?.syncAnimatedImageToWordAudio,
DEFAULT_ANKI_CONNECT_CONFIG.media.syncAnimatedImageToWordAudio,
);
}); });
test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', () => { test('AnkiIntegrationRuntime starts proxy transport when proxy mode is enabled', () => {
@@ -106,3 +110,77 @@ test('AnkiIntegrationRuntime switches transports and clears known words when run
'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765', 'proxy:start:127.0.0.1:8766:http://127.0.0.1:8765',
]); ]);
}); });
test('AnkiIntegrationRuntime skips known-word lifecycle restart for unrelated runtime patches', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
},
pollingRate: 250,
});
runtime.start();
calls.length = 0;
runtime.applyRuntimeConfigPatch({
behavior: {
autoUpdateNewCards: false,
},
});
assert.deepEqual(calls, []);
});
test('AnkiIntegrationRuntime restarts known-word lifecycle when known-word settings change', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
refreshMinutes: 90,
},
pollingRate: 250,
});
runtime.start();
calls.length = 0;
runtime.applyRuntimeConfigPatch({
knownWords: {
refreshMinutes: 120,
},
});
assert.deepEqual(calls, ['known:start']);
});
test('AnkiIntegrationRuntime does not stop lifecycle when disabled while runtime is stopped', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
},
});
runtime.applyRuntimeConfigPatch({
knownWords: {
highlightEnabled: false,
},
});
assert.deepEqual(calls, ['known:clear']);
});
test('AnkiIntegrationRuntime does not restart known-word lifecycle for config changes while stopped', () => {
const { runtime, calls } = createRuntime({
knownWords: {
highlightEnabled: true,
refreshMinutes: 90,
},
});
runtime.applyRuntimeConfigPatch({
knownWords: {
refreshMinutes: 120,
},
});
assert.deepEqual(calls, []);
});

View File

@@ -1,5 +1,10 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config'; import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import type { AnkiConnectConfig } from '../types'; import type { AnkiConnectConfig } from '../types';
import {
getKnownWordCacheLifecycleConfig,
getKnownWordCacheRefreshIntervalMinutes,
getKnownWordCacheScopeForConfig,
} from './known-word-cache';
export interface AnkiIntegrationRuntimeProxyServer { export interface AnkiIntegrationRuntimeProxyServer {
start(options: { host: string; port: number; upstreamUrl: string }): void; start(options: { host: string; port: number; upstreamUrl: string }): void;
@@ -145,6 +150,9 @@ export class AnkiIntegrationRuntime {
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void { applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true; const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
? this.getKnownWordCacheLifecycleConfig(this.config)
: null;
const previousTransportKey = this.getTransportConfigKey(this.config); const previousTransportKey = this.getTransportConfigKey(this.config);
const mergedConfig: AnkiConnectConfig = { const mergedConfig: AnkiConnectConfig = {
@@ -191,11 +199,22 @@ export class AnkiIntegrationRuntime {
}; };
this.config = normalizeAnkiIntegrationConfig(mergedConfig); this.config = normalizeAnkiIntegrationConfig(mergedConfig);
this.deps.onConfigChanged?.(this.config); this.deps.onConfigChanged?.(this.config);
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
if (wasKnownWordCacheEnabled && this.config.knownWords?.highlightEnabled === false) { if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
this.deps.knownWordCache.stopLifecycle(); if (this.started) {
this.deps.knownWordCache.stopLifecycle();
}
this.deps.knownWordCache.clearKnownWordCacheState(); this.deps.knownWordCache.clearKnownWordCacheState();
} else { } else if (this.started && !wasKnownWordCacheEnabled && nextKnownWordCacheEnabled) {
this.deps.knownWordCache.startLifecycle();
} else if (
this.started &&
wasKnownWordCacheEnabled &&
nextKnownWordCacheEnabled &&
previousKnownWordCacheConfig !== null &&
previousKnownWordCacheConfig !== this.getKnownWordCacheLifecycleConfig(this.config)
) {
this.deps.knownWordCache.startLifecycle(); this.deps.knownWordCache.startLifecycle();
} }
@@ -206,6 +225,18 @@ export class AnkiIntegrationRuntime {
} }
} }
private getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheLifecycleConfig(config);
}
private getKnownWordRefreshIntervalMinutes(config: AnkiConnectConfig): number {
return getKnownWordCacheRefreshIntervalMinutes(config);
}
private getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): string {
return getKnownWordCacheScopeForConfig(config);
}
getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer { getOrCreateProxyServer(): AnkiIntegrationRuntimeProxyServer {
if (!this.proxyServer) { if (!this.proxyServer) {
this.proxyServer = this.deps.proxyServerFactory(); this.proxyServer = this.deps.proxyServerFactory();

View File

@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginUpdateProgress,
createUiFeedbackState,
showProgressTick,
showUpdateResult,
} from './ui-feedback';
test('showUpdateResult stops spinner before success notification and suppresses stale ticks', () => {
const state = createUiFeedbackState();
const osdMessages: string[] = [];
beginUpdateProgress(state, 'Creating sentence card', () => {
showProgressTick(state, (text) => {
osdMessages.push(text);
});
});
showUpdateResult(
state,
{
clearProgressTimer: (timer) => {
clearInterval(timer);
},
showOsdNotification: (text) => {
osdMessages.push(text);
},
},
{ success: true, message: 'Updated card: taberu' },
);
showProgressTick(state, (text) => {
osdMessages.push(text);
});
assert.deepEqual(osdMessages, ['Creating sentence card |', '✓ Updated card: taberu']);
});
test('showUpdateResult renders failed updates with an x marker', () => {
const state = createUiFeedbackState();
const osdMessages: string[] = [];
beginUpdateProgress(state, 'Creating sentence card', () => {
showProgressTick(state, (text) => {
osdMessages.push(text);
});
});
showUpdateResult(
state,
{
clearProgressTimer: (timer) => {
clearInterval(timer);
},
showOsdNotification: (text) => {
osdMessages.push(text);
},
},
{ success: false, message: 'Sentence card failed: deck missing' },
);
assert.deepEqual(osdMessages, [
'Creating sentence card |',
'x Sentence card failed: deck missing',
]);
});

View File

@@ -7,6 +7,11 @@ export interface UiFeedbackState {
progressFrame: number; progressFrame: number;
} }
export interface UiFeedbackResult {
success: boolean;
message: string;
}
export interface UiFeedbackNotificationContext { export interface UiFeedbackNotificationContext {
getNotificationType: () => string | undefined; getNotificationType: () => string | undefined;
showOsd: (text: string) => void; showOsd: (text: string) => void;
@@ -66,6 +71,15 @@ export function endUpdateProgress(
state.progressDepth = Math.max(0, state.progressDepth - 1); state.progressDepth = Math.max(0, state.progressDepth - 1);
if (state.progressDepth > 0) return; if (state.progressDepth > 0) return;
clearUpdateProgress(state, clearProgressTimer);
}
export function clearUpdateProgress(
state: UiFeedbackState,
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void,
): void {
state.progressDepth = 0;
if (state.progressTimer) { if (state.progressTimer) {
clearProgressTimer(state.progressTimer); clearProgressTimer(state.progressTimer);
state.progressTimer = null; state.progressTimer = null;
@@ -85,6 +99,19 @@ export function showProgressTick(
showOsdNotification(`${state.progressMessage} ${frame}`); showOsdNotification(`${state.progressMessage} ${frame}`);
} }
export function showUpdateResult(
state: UiFeedbackState,
options: {
clearProgressTimer: (timer: ReturnType<typeof setInterval>) => void;
showOsdNotification: (text: string) => void;
},
result: UiFeedbackResult,
): void {
clearUpdateProgress(state, options.clearProgressTimer);
const prefix = result.success ? '✓' : 'x';
options.showOsdNotification(`${prefix} ${result.message}`);
}
export async function withUpdateProgress<T>( export async function withUpdateProgress<T>(
state: UiFeedbackState, state: UiFeedbackState,
options: UiFeedbackOptions, options: UiFeedbackOptions,

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
hasExplicitCommand, hasExplicitCommand,
isHeadlessInitialCommand,
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
@@ -101,7 +102,8 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const refreshKnownWords = parseArgs(['--refresh-known-words']); const refreshKnownWords = parseArgs(['--refresh-known-words']);
assert.equal(refreshKnownWords.help, false); assert.equal(refreshKnownWords.help, false);
assert.equal(hasExplicitCommand(refreshKnownWords), true); assert.equal(hasExplicitCommand(refreshKnownWords), true);
assert.equal(shouldStartApp(refreshKnownWords), false); assert.equal(shouldStartApp(refreshKnownWords), true);
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
const settings = parseArgs(['--settings']); const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true); assert.equal(settings.settings, true);

Some files were not shown because too many files have changed in this diff Show More