Compare commits
14 Commits
b061b265c2
...
ec56970646
| Author | SHA1 | Date | |
|---|---|---|---|
| ec56970646 | |||
|
48f10dbb03
|
|||
|
1cb129b0b7
|
|||
|
af1505fbe6
|
|||
|
97126caf4e
|
|||
|
a0015dc75c
|
|||
|
61e1621137
|
|||
|
a5b1c0509d
|
|||
|
e694963426
|
|||
|
a69254f976
|
|||
|
a1348cf8e4
|
|||
|
f9b582582b
|
|||
|
8f39416ff5
|
|||
|
ecb41a490b
|
21
.github/workflows/release.yml
vendored
@@ -89,14 +89,17 @@ jobs:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/texthooker-ui/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build texthooker-ui
|
||||
run: |
|
||||
@@ -144,9 +147,10 @@ jobs:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/texthooker-ui/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
@@ -171,7 +175,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build texthooker-ui
|
||||
run: |
|
||||
@@ -216,14 +222,17 @@ jobs:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/texthooker-ui/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build texthooker-ui
|
||||
shell: powershell
|
||||
|
||||
5
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
|
||||
# Electron build output
|
||||
out/
|
||||
dist/
|
||||
@@ -22,9 +25,7 @@ Thumbs.db
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
**/CLAUDE.md
|
||||
environment.toml
|
||||
**/CLAUDE.md
|
||||
.env
|
||||
.vscode/*
|
||||
|
||||
|
||||
63
README.md
@@ -22,15 +22,54 @@
|
||||
|
||||
## 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:
|
||||
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.
|
||||
|
||||
- **Look up words as you watch** — Yomitan dictionary popups on hover or keyboard-driven token-by-token navigation
|
||||
- **One-key Anki mining** — Creates cards with sentence, audio, screenshot, and translation; optional local AnkiConnect proxy auto-enriches Yomitan cards instantly
|
||||
- **Reading annotations** — N+1 targeting, frequency-dictionary highlighting, JLPT underlining, and character name dictionary for anime/manga proper nouns
|
||||
- **Immersion stats** — Optional local dashboard and overlay for watch time, anime progress, session drill-down, vocabulary growth, mining throughput, and card mining directly from example sentences; exact lifetime totals are kept locally in SQLite by default
|
||||
## Features
|
||||
|
||||
### Dictionary Lookups While You Watch
|
||||
|
||||
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 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">
|
||||
<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">
|
||||
</div>
|
||||
|
||||
### Reading Annotations
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
### Immersion Dashboard
|
||||
|
||||
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">
|
||||
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard — overview 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>
|
||||
|
||||
### External Integrations
|
||||
|
||||
- **AniList** — Automatic episode progress tracking
|
||||
- **Jellyfin** — Remote playback, cast device mode
|
||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||
- **Jellyfin & AniList integration** — Remote playback, cast device mode, and automatic episode progress tracking
|
||||
- **Texthooker & API** — Built-in texthooker page and annotated websocket feed for external clients
|
||||
- **Texthooker & API** — Custom texthooker page and annotated websocket feed for external clients
|
||||
|
||||
<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">
|
||||
</div>
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -55,10 +94,10 @@ makepkg -si
|
||||
**Linux (AppImage):**
|
||||
|
||||
```bash
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
|
||||
chmod +x ~/.local/bin/SubMiner.AppImage
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
||||
chmod +x ~/.local/bin/subminer
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||
&& chmod +x ~/.local/bin/subminer
|
||||
|
||||
```
|
||||
|
||||
@@ -69,7 +108,7 @@ chmod +x ~/.local/bin/subminer
|
||||
|
||||
**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`.
|
||||
|
||||
**From source** — initialize submodules first (`git submodule update --init --recursive`). Bundled Yomitan is built from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`, so source builds only need Bun for the JS toolchain. Packaged macOS and Windows installs do not require Bun. Windows installer builds go through `electron-builder`; its bundled `app-builder-lib` NSIS templates already use the third-party `WinShell` plugin for shortcut AppUserModelID assignment, and the `WinShell.dll` binary is supplied by electron-builder's cached `nsis-resources` bundle, so `bun run build:win` does not need a separate repo-local plugin install step. Full install guide: [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
||||
**From source** — see [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
||||
|
||||
### 2. Launch the app once
|
||||
|
||||
|
||||
8
backlog/milestones/m-1 - stats-dashboard.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
id: m-1
|
||||
title: "Stats Dashboard"
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Milestone: Stats Dashboard
|
||||
@@ -4,6 +4,7 @@ title: Add Jellyfin remote-session subtitle streaming to texthooker
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-08 03:46'
|
||||
updated_date: '2026-03-18 05:27'
|
||||
labels:
|
||||
- jellyfin
|
||||
- texthooker
|
||||
@@ -19,20 +20,17 @@ references:
|
||||
documentation:
|
||||
- 'https://api.jellyfin.org/'
|
||||
priority: medium
|
||||
ordinal: 1000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Allow SubMiner to follow subtitles from a separate Jellyfin client session, such as a TV app, without requiring local mpv playback. The feature should fetch the active subtitle stream from Jellyfin, map the remote playback position to subtitle cues, and feed the existing subtitle tokenization plus annotated texthooker websocket pipeline so texthooker-only mode can be used while watching on another device.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [ ] #1 User can target a remote Jellyfin session and stream its current subtitle cue into SubMiner's existing subtitle-processing pipeline without launching local Jellyfin playback in mpv.
|
||||
- [ ] #2 Texthooker-only mode can display subtitle updates from the tracked remote Jellyfin session through the existing annotation websocket feed.
|
||||
- [ ] #3 Remote session changes are handled safely: item changes, subtitle-track changes, pause/seek/stop, and session disconnects clear or refresh subtitle state without crashing.
|
||||
|
||||
@@ -4,6 +4,7 @@ title: Add native AI API key secret storage
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-08 07:25'
|
||||
updated_date: '2026-03-18 05:27'
|
||||
labels:
|
||||
- ai
|
||||
- config
|
||||
@@ -17,20 +18,17 @@ references:
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/jellyfin-token-store.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||
priority: medium
|
||||
ordinal: 2000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Store the shared AI provider API key using the app's native secret-storage pattern so users do not need to keep the OpenRouter key in config files or shell commands.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [ ] #1 Users can configure the shared AI provider without storing the API key in config.jsonc.
|
||||
- [ ] #2 The app persists and reloads the shared AI API key using encrypted native secret storage when available.
|
||||
- [ ] #3 Behavior is defined for existing ai.apiKey and ai.apiKeyCommand configs, including compatibility during migration.
|
||||
|
||||
@@ -7,14 +7,14 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- enhancement
|
||||
- overlay
|
||||
- mpv
|
||||
- aniskip
|
||||
dependencies: []
|
||||
ordinal: 42500
|
||||
ordinal: 43500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 18:24'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
@@ -19,7 +19,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/scripts/get-mpv-window-macos.swift
|
||||
priority: high
|
||||
ordinal: 52500
|
||||
ordinal: 53500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
id: TASK-133
|
||||
title: Improve AniList character dictionary parity with upstream guide
|
||||
status: In Progress
|
||||
status: To Do
|
||||
assignee:
|
||||
- OpenCode
|
||||
created_date: '2026-03-08 21:06'
|
||||
updated_date: '2026-03-10 06:18'
|
||||
updated_date: '2026-03-18 05:27'
|
||||
labels:
|
||||
- dictionary
|
||||
- anilist
|
||||
@@ -24,6 +24,7 @@ documentation:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-08-anilist-character-dictionary-parity.md
|
||||
priority: high
|
||||
ordinal: 3000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- ci
|
||||
- release
|
||||
@@ -18,7 +18,7 @@ references:
|
||||
- src/release-workflow.test.ts
|
||||
- 'https://github.com/ksyasuda/SubMiner/actions/runs/22836585479'
|
||||
priority: high
|
||||
ordinal: 51500
|
||||
ordinal: 52500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 20:24'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- patch
|
||||
@@ -16,7 +16,7 @@ references:
|
||||
- CHANGELOG.md
|
||||
- release/release-notes.md
|
||||
priority: high
|
||||
ordinal: 50500
|
||||
ordinal: 51500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 20:41'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- ci
|
||||
- release
|
||||
@@ -18,7 +18,7 @@ references:
|
||||
- build/signpath-windows-artifact-config.xml
|
||||
- src/release-workflow.test.ts
|
||||
priority: high
|
||||
ordinal: 48500
|
||||
ordinal: 49500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 20:44'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- patch
|
||||
@@ -16,7 +16,7 @@ references:
|
||||
- CHANGELOG.md
|
||||
- release/release-notes.md
|
||||
priority: high
|
||||
ordinal: 49500
|
||||
ordinal: 50500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- windows
|
||||
@@ -15,7 +15,7 @@ references:
|
||||
- package.json
|
||||
- src/release-workflow.test.ts
|
||||
priority: high
|
||||
ordinal: 44500
|
||||
ordinal: 45500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- patch
|
||||
@@ -16,7 +16,7 @@ references:
|
||||
- CHANGELOG.md
|
||||
- release/release-notes.md
|
||||
priority: high
|
||||
ordinal: 45500
|
||||
ordinal: 46500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Fix guessit title parsing for character dictionary sync
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- dictionary
|
||||
- anilist
|
||||
@@ -17,7 +17,7 @@ references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-updater.test.ts
|
||||
priority: high
|
||||
ordinal: 43500
|
||||
ordinal: 44500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Refresh current subtitle after character dictionary sync completes
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- dictionary
|
||||
- overlay
|
||||
@@ -15,7 +15,7 @@ references:
|
||||
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||
priority: high
|
||||
ordinal: 41500
|
||||
ordinal: 42500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Show character dictionary auto-sync progress on OSD
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-09 01:10'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- dictionary
|
||||
- overlay
|
||||
@@ -17,7 +17,7 @@ references:
|
||||
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||
priority: medium
|
||||
ordinal: 40500
|
||||
ordinal: 41500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Keep character dictionary auto-sync non-blocking during startup
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-09 01:45'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- dictionary
|
||||
- startup
|
||||
@@ -17,7 +17,7 @@ references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/current-media-tokenization-gate.ts
|
||||
priority: high
|
||||
ordinal: 37500
|
||||
ordinal: 38500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -6,7 +6,7 @@ title: >-
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-09 10:40'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- startup
|
||||
- overlay
|
||||
@@ -21,7 +21,7 @@ references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||
priority: medium
|
||||
ordinal: 36500
|
||||
ordinal: 37500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,14 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- overlay
|
||||
- aniskip
|
||||
- linux
|
||||
dependencies: []
|
||||
ordinal: 46500
|
||||
ordinal: 47500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,14 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 00:00'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- windows
|
||||
- plugin
|
||||
- regression
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 47500
|
||||
ordinal: 48500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 01:10'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- patch
|
||||
@@ -25,7 +25,7 @@ references:
|
||||
- scripts/build-changelog.test.ts
|
||||
- docs/RELEASING.md
|
||||
priority: high
|
||||
ordinal: 38500
|
||||
ordinal: 39500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-09 01:11'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- tooling
|
||||
- formatting
|
||||
@@ -20,7 +20,7 @@ references:
|
||||
- scripts/build-win-unsigned.mjs
|
||||
- src
|
||||
priority: medium
|
||||
ordinal: 39500
|
||||
ordinal: 40500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-11 08:28'
|
||||
updated_date: '2026-03-16 06:25'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- linux
|
||||
- packaging
|
||||
@@ -20,6 +20,7 @@ references:
|
||||
- docs-site/launcher-script.md
|
||||
- README.md
|
||||
priority: medium
|
||||
ordinal: 116500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
id: TASK-166
|
||||
title: Harden SubMiner change verification for authoritative agentic runtime checks
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 05:19'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
labels:
|
||||
- testing
|
||||
- agents
|
||||
- verification
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/SKILL.md
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/testing-plan.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs-site/development.md
|
||||
ordinal: 22500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Tighten the SubMiner change-verification classifier and verifier so the implementation matches the approved agentic verification plan: authoritative runtime verification must fail closed when unavailable, lane naming should use real-runtime semantics, session and artifact identities must be unique, and the verifier must be safer for parallel agent use.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The verifier uses `real-runtime` terminology instead of `real-gui` for authoritative runtime verification
|
||||
- [x] #2 Requested authoritative runtime verification fails closed with a non-green outcome when it cannot run, and unknown lanes do not pass open
|
||||
- [x] #3 The verifier allocates a unique session identifier and artifact root that does not rely on second-level timestamp uniqueness alone
|
||||
- [x] #4 The verifier summary/report output includes explicit top-level status and session metadata needed for agent aggregation
|
||||
- [x] #5 The classifier and verifier better reflect runtime-escalation cases for launcher/plugin/socket/runtime-sensitive changes
|
||||
- [x] #6 Regression tests cover the new verifier/classifier behavior
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression tests for classifier/verifier behavior before changing the scripts.
|
||||
2. Harden `verify_subminer_change.sh` to use `real-runtime` terminology, fail closed for blocked/unknown authoritative verification, and emit unique session metadata in summaries.
|
||||
3. Update `classify_subminer_diff.sh` and the skill doc to use `real-runtime` escalation language and better flag launcher/plugin/runtime-sensitive paths.
|
||||
4. Run targeted regression tests plus a focused dry-run verifier check, then record outcomes and blockers in the task.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added `scripts/subminer-change-verification.test.ts` to regression-test classifier/verifier behavior around `real-runtime` naming, fail-closed authoritative verification, unknown lanes, and unique session metadata.
|
||||
|
||||
Reworked `verify_subminer_change.sh` to normalize `real-gui` to `real-runtime`, emit unique session IDs and richer summary metadata, block authoritative runtime verification when unavailable, and fail closed for unknown lanes.
|
||||
|
||||
Updated `classify_subminer_diff.sh` to emit `real-runtime-candidate` for launcher/plugin/runtime-sensitive paths, and updated the active skill doc wording to match the new lane terminology.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Hardened the SubMiner change-verification tooling to match the approved agentic verification plan. The verifier now uses `real-runtime` terminology for authoritative runtime verification, preserves compatibility with the deprecated `real-gui` alias, fails closed for unknown lanes, and returns a blocked non-green outcome when requested authoritative runtime verification cannot run. It now allocates a unique session ID and artifact root by default, writes richer session metadata and top-level status into `summary.json`/`summary.txt` plus `reports/summary.*`, and records path selection mode, blockers, and session-local env roots for agent aggregation. The classifier now emits `real-runtime-candidate` for launcher/plugin/runtime-sensitive paths, and the active skill doc uses the same terminology. Verification ran via `bun test scripts/subminer-change-verification.test.ts`, direct dry-run smoke checks for blocked `real-runtime` and failed unknown-lane execution, and a targeted classifier invocation for launcher/plugin paths.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-17 18:10'
|
||||
updated_date: '2026-03-17 18:14'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- release
|
||||
- packaging
|
||||
@@ -16,9 +16,12 @@ references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/.github/workflows/release.yml
|
||||
- /home/sudacode/projects/japanese/SubMiner/scripts/update-aur-package.sh
|
||||
- /home/sudacode/projects/japanese/SubMiner/scripts/update-aur-package.test.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/packaging/aur/subminer-bin/PKGBUILD
|
||||
- /home/sudacode/projects/japanese/SubMiner/packaging/aur/subminer-bin/.SRCINFO
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/packaging/aur/subminer-bin/PKGBUILD
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/packaging/aur/subminer-bin/.SRCINFO
|
||||
priority: medium
|
||||
ordinal: 107500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -11,6 +11,7 @@ labels:
|
||||
- stats
|
||||
- database
|
||||
- anilist
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
---
|
||||
id: TASK-170
|
||||
title: 'Fix imm_words POS filtering and add stats cleanup maintenance command'
|
||||
title: Fix imm_words POS filtering and add stats cleanup maintenance command
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 00:00'
|
||||
updated_date: '2026-03-14 18:31'
|
||||
updated_date: '2026-03-18 05:31'
|
||||
labels: []
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 9010
|
||||
@@ -14,25 +15,19 @@ ordinal: 9010
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
`imm_words` is currently populated from raw subtitle text instead of tokenized subtitle metadata, so ignored functional/noise tokens leak into stats and no POS metadata is stored. Fix live persistence to follow the existing token annotation exclusion rules and add an on-demand stats cleanup command to remove stale bad vocabulary rows from the stats DB.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 New `imm_words` inserts use tokenized subtitle data, persist POS metadata, and skip tokens excluded by existing POS-based vocabulary ignore rules.
|
||||
- [x] #2 `subminer stats cleanup` supports `-v` / `--vocab`, defaults to vocab cleanup, and removes stale bad `imm_words` rows on demand.
|
||||
- [x] #3 Regression coverage exists for persistence filtering, cleanup behavior, and stats cleanup CLI wiring.
|
||||
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
Fixed `imm_words` persistence so the tracker now consumes tokenized subtitle data, stores POS metadata (`part_of_speech`, `pos1`, `pos2`, `pos3`), preserves distinct surface/lemma fields (`word` vs `headword`) when tokenization provides them, and skips vocabulary rows excluded by the existing POS/noise rules instead of mining raw subtitle fragments. Added `subminer stats cleanup` with default vocab cleanup plus `-v/--vocab`; the cleanup pass now repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still have no usable POS metadata or fail the vocab filters.
|
||||
|
||||
Verification:
|
||||
@@ -41,5 +36,4 @@ Verification:
|
||||
- `bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/storage-session.test.ts launcher/parse-args.test.ts launcher/commands/command-modules.test.ts src/main/runtime/stats-cli-command.test.ts src/main/runtime/mpv-main-event-main-deps.test.ts src/core/services/cli-command.test.ts`
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
||||
@@ -10,6 +10,7 @@ labels:
|
||||
- immersion
|
||||
- stats
|
||||
- database
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
|
||||
@@ -5,20 +5,24 @@ status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-16 10:45'
|
||||
updated_date: '2026-03-16 23:04'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
- overlay
|
||||
dependencies: []
|
||||
references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-window.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-runtime-init.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/window-trackers/macos-tracker.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-window.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-runtime-init.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/window-trackers/macos-tracker.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||
priority: high
|
||||
ordinal: 53000
|
||||
ordinal: 54500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
id: TASK-172
|
||||
title: Wire immersion occurrence drilldown into stats API and vocabulary drawer
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 12:05'
|
||||
updated_date: '2026-03-16 05:13'
|
||||
labels:
|
||||
- immersion
|
||||
- stats
|
||||
- ui
|
||||
dependencies:
|
||||
- TASK-171
|
||||
ordinal: 18500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Expose the new immersion word/kanji occurrence queries through the stats server and add a right-side Vocabulary drawer that shows recent occurrence rows with paging when a word or kanji is clicked.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Stats server exposes word and kanji occurrence endpoints with bounded recent-first paging.
|
||||
- [x] #2 Stats client/types support loading occurrence pages for a selected word or kanji.
|
||||
- [x] #3 Vocabulary tab opens a right drawer for the selected word/kanji, shows recent occurrences, and supports loading more.
|
||||
- [x] #4 Focused regression coverage exists for the server endpoint contract, and the stats UI still typechecks/builds.
|
||||
- [x] #5 Verification covers the cheapest sufficient backend and stats-UI lanes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-14: Design approved in-thread. Chosen UX: click a word chip or kanji glyph to open a right-side drawer with recent-first occurrences, initial cap 50, plus “Load more”.
|
||||
2026-03-14: Implemented `/api/stats/vocabulary/occurrences` and `/api/stats/kanji/occurrences` with `limit` + `offset` paging. The drawer uses direct stats HTTP client calls and keeps existing aggregate vocabulary data flow intact.
|
||||
2026-03-14: Verification commands run:
|
||||
- `bun test src/core/services/__tests__/stats-server.test.ts`
|
||||
- `bun run typecheck`
|
||||
- `cd stats && bun run build`
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
- `cd stats && bunx tsc --noEmit`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/stats-server.ts src/core/services/__tests__/stats-server.test.ts`
|
||||
2026-03-14: Verification results:
|
||||
- stats server endpoint tests: passed
|
||||
- root typecheck: passed
|
||||
- stats UI production build: passed
|
||||
- docs-site test/build: passed
|
||||
- `cd stats && bunx tsc --noEmit`: passed after removing stale `hasCoverArt` prop usage in the library stats UI
|
||||
- verifier result: passed (`typecheck`, `test:fast`)
|
||||
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260314-120900-J0VvB0/`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Wired occurrence drilldown into the stats server and Vocabulary tab. Words and kanji now open a right-side drawer that loads recent occurrence rows 50 at a time and supports “Load more”.
|
||||
|
||||
Added bounded recent-first occurrence endpoints to the stats HTTP API, extended the stats client/type surface, and made word chips plus kanji glyphs selectable with active-state styling.
|
||||
|
||||
Updated the immersion-tracking docs to mention vocabulary occurrence drilldown. Verified with focused stats-server tests, root typecheck, stats UI production build, docs-site test/build, the repo verifier core lane, and a direct `stats` package typecheck after removing the stale `MediaHeader` prop mismatch.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -8,6 +8,7 @@ updated_date: '2026-03-16 05:13'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
priority: low
|
||||
ordinal: 17500
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-15 10:18'
|
||||
updated_date: '2026-03-16 06:46'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- tokenizer
|
||||
@@ -20,6 +20,7 @@ references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/scripts/get_frequency.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/scripts/test-yomitan-parser.ts
|
||||
priority: high
|
||||
ordinal: 115500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,11 +5,12 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 09:15'
|
||||
updated_date: '2026-03-17 09:41'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- immersion-tracking
|
||||
- yomitan
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- vendor/subminer-yomitan/ext/js/app/frontend.js
|
||||
@@ -23,6 +24,7 @@ references:
|
||||
documentation:
|
||||
- docs/plans/2026-03-17-yomitan-lookup-stats-design.md
|
||||
priority: medium
|
||||
ordinal: 114500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,11 +5,12 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 14:59'
|
||||
updated_date: '2026-03-17 15:13'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- pr-review
|
||||
- immersion-tracker
|
||||
- stats
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
@@ -21,6 +22,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||
priority: medium
|
||||
ordinal: 113500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 15:15'
|
||||
updated_date: '2026-03-17 15:19'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- sqlite
|
||||
- immersion-tracking
|
||||
@@ -15,6 +15,7 @@ documentation:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-17-sqlite-tuning.md
|
||||
priority: medium
|
||||
ordinal: 111500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,11 +5,12 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 15:16'
|
||||
updated_date: '2026-03-17 15:18'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- launcher
|
||||
- stats
|
||||
- tests
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
@@ -18,6 +19,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/launcher/commands/command-modules.test.ts
|
||||
priority: medium
|
||||
ordinal: 112500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,13 +5,15 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 15:31'
|
||||
updated_date: '2026-03-17 15:55'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- cli
|
||||
- launcher
|
||||
- stats
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 110500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
id: TASK-182
|
||||
title: Fix session stats chart known-word totals exceeding total words
|
||||
status: Done
|
||||
milestone: m-1
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 16:07'
|
||||
updated_date: '2026-03-17 16:19'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
@@ -15,6 +16,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/__tests__/stats-server.test.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/stats/src/hooks/useSessions.ts
|
||||
ordinal: 109500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,11 +5,12 @@ status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-18 01:41'
|
||||
updated_date: '2026-03-18 01:45'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
@@ -19,6 +20,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/lib/media-session-list.test.tsx
|
||||
parent_task_id: TASK-182
|
||||
ordinal: 101500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
id: TASK-183
|
||||
title: Fix blank stats vocabulary page regression
|
||||
status: Done
|
||||
milestone: m-1
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 16:23'
|
||||
updated_date: '2026-03-17 16:29'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
@@ -13,6 +14,7 @@ references:
|
||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/components/vocabulary/VocabularyTab.tsx
|
||||
- /Users/sudacode/projects/japanese/SubMiner/stats/src/App.tsx
|
||||
- /Users/sudacode/projects/japanese/SubMiner/stats/src/lib/api-client.ts
|
||||
ordinal: 108500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-17 19:28'
|
||||
updated_date: '2026-03-17 19:31'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stabilization
|
||||
- ci
|
||||
@@ -14,6 +14,7 @@ references:
|
||||
- package.json
|
||||
- docs/workflow/verification.md
|
||||
priority: medium
|
||||
ordinal: 106500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,11 +5,12 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 22:58'
|
||||
updated_date: '2026-03-17 23:00'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- bug
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
@@ -18,6 +19,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/query.ts
|
||||
priority: medium
|
||||
ordinal: 104500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,10 +5,11 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 23:19'
|
||||
updated_date: '2026-03-17 23:29'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/stats/src/App.tsx
|
||||
@@ -21,6 +22,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/components/library/MediaDetailView.tsx
|
||||
priority: medium
|
||||
ordinal: 103500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,10 +5,11 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 23:42'
|
||||
updated_date: '2026-03-17 23:57'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
@@ -31,6 +32,7 @@ documentation:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-17-episode-detail-session-accordion.md
|
||||
priority: medium
|
||||
ordinal: 102500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
---
|
||||
id: TASK-187.1
|
||||
title: Auto-expand targeted session when opening media detail
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-18 01:32'
|
||||
updated_date: '2026-03-18 01:36'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- stats/src/lib/stats-navigation.ts
|
||||
@@ -19,6 +20,7 @@ references:
|
||||
- stats/src/lib/stats-navigation.test.ts
|
||||
parent_task_id: TASK-187
|
||||
priority: medium
|
||||
ordinal: 117500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -29,11 +31,11 @@ When a navigation path opens episode/media detail with a known session ID, the m
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Media detail navigation state can carry an optional target session ID alongside the selected video.
|
||||
- [ ] #2 Any navigation path that opens media detail with a known session ID causes that session row to auto-expand when the episode history loads.
|
||||
- [ ] #3 Session-tab fallback for orphan sessions without a video still behaves as it does now.
|
||||
- [ ] #4 Media detail auto-expansion clears or stabilizes its one-shot navigation state so normal manual expand/collapse behavior still works after landing.
|
||||
- [ ] #5 Relevant navigation/component tests cover the targeted media-detail auto-expand behavior.
|
||||
- [x] #1 Media detail navigation state can carry an optional target session ID alongside the selected video.
|
||||
- [x] #2 Any navigation path that opens media detail with a known session ID causes that session row to auto-expand when the episode history loads.
|
||||
- [x] #3 Session-tab fallback for orphan sessions without a video still behaves as it does now.
|
||||
- [x] #4 Media detail auto-expansion clears or stabilizes its one-shot navigation state so normal manual expand/collapse behavior still works after landing.
|
||||
- [x] #5 Relevant navigation/component tests cover the targeted media-detail auto-expand behavior.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-188
|
||||
title: Refactor stats chart data pipeline to use backend-aggregated series
|
||||
status: In Progress
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-18 00:29'
|
||||
@@ -10,6 +10,7 @@ labels:
|
||||
- stats
|
||||
- performance
|
||||
- refactor
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/immersion-tracker/query.ts
|
||||
@@ -31,12 +32,12 @@ Reduce long-term dashboard performance debt by moving chart aggregation out of t
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Stats API exposes chart-oriented aggregated trend data needed by the trends dashboard without requiring raw session lists for those charts.
|
||||
- [ ] #2 The trends dashboard consumes the new aggregated API responses and no longer rebuilds its main chart datasets from raw sessions in the render path.
|
||||
- [ ] #3 Time-range and grouping behavior remain correct for recent and all-time views, with explicit handling that keeps older history performant.
|
||||
- [ ] #4 Existing overview and anime detail charts continue to behave correctly, or are migrated to the shared aggregation path where it reduces debt.
|
||||
- [ ] #5 Tests cover backend aggregation/query behavior and frontend consumption of the new response shapes.
|
||||
- [ ] #6 Internal docs are updated to describe the new stats chart data flow and scaling rationale.
|
||||
- [x] #1 Stats API exposes chart-oriented aggregated trend data needed by the trends dashboard without requiring raw session lists for those charts.
|
||||
- [x] #2 The trends dashboard consumes the new aggregated API responses and no longer rebuilds its main chart datasets from raw sessions in the render path.
|
||||
- [x] #3 Time-range and grouping behavior remain correct for recent and all-time views, with explicit handling that keeps older history performant.
|
||||
- [x] #4 Existing overview and anime detail charts continue to behave correctly, or are migrated to the shared aggregation path where it reduces debt.
|
||||
- [x] #5 Tests cover backend aggregation/query behavior and frontend consumption of the new response shapes.
|
||||
- [x] #6 Internal docs are updated to describe the new stats chart data flow and scaling rationale.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
id: TASK-189
|
||||
title: Replace stats word counts with Yomitan token counts
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-18 01:35'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- tokenizer
|
||||
- bug
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/immersion-tracker-service.ts
|
||||
- src/core/services/immersion-tracker/reducer.ts
|
||||
- src/core/services/immersion-tracker/storage.ts
|
||||
- src/core/services/immersion-tracker/query.ts
|
||||
- src/core/services/immersion-tracker/lifetime.ts
|
||||
- stats/src/components
|
||||
- stats/src/lib/yomitan-lookup.ts
|
||||
priority: medium
|
||||
ordinal: 100500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace heuristic immersion stats word counting with Yomitan token counts. Session/media/anime stats should use the exact merged Yomitan token stream as the denominator and display metric, with no whitespace/CJK-character fallback and no active `wordsSeen` concept in the runtime, storage, API, or stats UI.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `recordSubtitleLine` derives session count deltas from Yomitan token arrays instead of `calculateTextMetrics`.
|
||||
- [x] #2 Active immersion tracking/storage/query code no longer depends on `wordsSeen` / `totalWordsSeen` fields for stats behavior.
|
||||
- [x] #3 Stats UI labels and lookup-rate copy refer to tokens instead of words where those counts are shown to users.
|
||||
- [x] #4 Regression tests cover token-count sourcing, zero-count behavior when tokenization payload is absent, and updated stats copy.
|
||||
- [x] #5 A changelog fragment documents the user-visible stats denominator change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing tracker tests proving subtitle count metrics come from Yomitan token arrays and stay zero when tokenization is absent.
|
||||
2. Add failing stats UI tests for token-based copy and token-count display helpers.
|
||||
3. Remove `wordsSeen` from active tracker/session/query/type paths and use `tokensSeen` as the single stats count field.
|
||||
4. Update stats UI labels and lookup-rate copy from words to tokens.
|
||||
5. Run targeted verification, then add the changelog fragment and any needed docs update.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
Completed. Stats subtitle counts now come directly from Yomitan merged-token counts, `wordsSeen` is removed from the active tracker/storage/query/UI path, token-facing copy is updated, and focused regression coverage plus `bun run typecheck` are green.
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: TASK-190
|
||||
title: Add hover popups for session chart events
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-17 22:20'
|
||||
updated_date: '2026-03-18 05:28'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
- bug
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- stats/src/components/sessions/SessionDetail.tsx
|
||||
- stats/src/lib/session-events.ts
|
||||
- stats/src/hooks/useSessions.ts
|
||||
- stats/src/lib/api-client.ts
|
||||
- docs/plans/2026-03-17-session-event-hover-popups-design.md
|
||||
priority: medium
|
||||
ordinal: 105500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add hover/focus popups to session chart event markers so pauses, seeks, lookups, and card-mine events explain themselves inline. Card-mine events should lazy-load available Anki note info and present it in a richer popup with browse affordances.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Hovering or focusing a session-chart marker opens an event-specific popup.
|
||||
- [x] #2 Pause, seek, and lookup popups show concise event copy derived from marker metadata.
|
||||
- [x] #3 Card-mine popups lazily fetch and cache Anki note info by note id.
|
||||
- [x] #4 Card-mine popups show a formatted fallback when note info is missing or still loading.
|
||||
- [x] #5 Regression tests cover event payload shaping and popup rendering behavior.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add failing tests for event metadata shaping and popup content selection.
|
||||
2. Extend session-event shaping to parse payload JSON into typed marker metadata.
|
||||
3. Add lazy note-info fetch/cache state for card-mine markers.
|
||||
4. Render interactive marker overlay + custom popup in the session detail chart.
|
||||
5. Run targeted stats/core verification and update this task with the result.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
Completed. Session-chart event markers now open event-specific hover/focus popups, including lazy-loaded Anki note info for card-mine events with browse affordances. Verification passed via targeted stats tests, `bun run typecheck`, and the core verification lane in `.tmp/skill-verification/subminer-verify-20260317-222545-CQzyqK`.
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: TASK-191
|
||||
title: 'Assess PR #19 CodeRabbit review follow-ups'
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 23:15'
|
||||
updated_date: '2026-03-17 23:18'
|
||||
labels:
|
||||
- pr-review
|
||||
- stats
|
||||
- immersion-tracker
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/immersion-tracker-service.ts
|
||||
- src/core/services/immersion-tracker-service.test.ts
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Validate the open CodeRabbit review comments on PR #19 against the current branch, implement only the confirmed fixes, and record which bot suggestions are stale or technically incomplete.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Each open CodeRabbit PR #19 comment is validated against the current branch behavior
|
||||
- [x] #2 Confirmed issues are fixed with regression coverage where it fits
|
||||
- [x] #3 Non-actionable or partially-wrong bot guidance is documented explicitly
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect the open CodeRabbit review threads on PR #19 and restate each finding in codebase terms.
|
||||
2. Add failing regression tests for any verified bugs before changing production code.
|
||||
3. Patch the smallest safe service-layer behavior, rerun focused verification, and record which suggestions were accepted versus rejected.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Validated the two open CodeRabbit inline findings on PR #19 against the current branch. Both reported real bugs in `ImmersionTrackerService`, but the first suggestion's exact remediation was incomplete for this codebase.
|
||||
|
||||
`reassignAnimeAnilist` did overwrite `imm_anime.description` with `NULL` when callers omitted `description`. Fixed with a presence-aware SQL update that preserves the existing description when the field is omitted while still allowing explicit `description: null` to clear the stored value. Rejected the bot's `COALESCE(?, description)` prompt because that would silently remove the explicit-clear behavior the API already supports.
|
||||
|
||||
`ensureCoverArt` could return `true` after a fetcher reported success even when no cover-art row/blob was stored, because `undefined !== null` evaluated truthy through optional chaining. Fixed by loading the row into a local variable and requiring a non-null blob.
|
||||
|
||||
Added regression coverage in `src/core/services/immersion-tracker-service.test.ts` for omitted-description preservation, explicit-null clearing, and the no-row `ensureCoverArt` false-positive case.
|
||||
|
||||
Verification passed:
|
||||
- `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-service.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-service.ts src/core/services/immersion-tracker-service.test.ts`
|
||||
|
||||
Verifier artifact directory: `.tmp/skill-verification/subminer-verify-20260317-231743-wHFNnN`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Assessed the open PR #19 CodeRabbit comments and fixed the two confirmed service-layer regressions. `reassignAnimeAnilist` now preserves an existing anime description when callers omit the `description` field but still clears it on explicit `null`, and `ensureCoverArt` no longer reports success when no cover-art row/blob exists after a fetch attempt.
|
||||
|
||||
Both comments were actionable, but one bot-proposed fix was not correct as written for this branch: replacing the description update with `COALESCE(?, description)` would have broken intentional description clearing. Added regression tests for the accepted behaviors and verified the change with the full touched service test file plus the SubMiner `core` verification lane.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
id: TASK-192
|
||||
title: 'Assess remaining PR #19 review batch'
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-17 23:24'
|
||||
updated_date: '2026-03-17 23:42'
|
||||
labels:
|
||||
- pr-review
|
||||
- stats
|
||||
- docs
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- docs/superpowers/plans/2026-03-12-immersion-stats-page.md
|
||||
- src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||
- src/core/services/ipc.ts
|
||||
- src/core/services/stats-server.ts
|
||||
- src/main.ts
|
||||
- src/renderer/handlers/keyboard.ts
|
||||
- stats/src
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Validate the remaining PR #19 automated review findings against the current branch, implement only the technically correct fixes, and document which comments are stale, already addressed, or not warranted.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Each remaining review comment is classified as actionable, already fixed, stale, or not warranted
|
||||
- [x] #2 Confirmed bugs or correctness issues are fixed with focused regression coverage where it fits
|
||||
- [x] #3 Final notes record which comments were intentionally not applied and why
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect the referenced files in batches and compare each comment against current branch behavior.
|
||||
2. Separate correctness/security regressions from stylistic nitpicks and already-fixed items.
|
||||
3. Add tests first for confirmed behavior bugs where practical, apply the smallest safe fixes, and rerun targeted verification.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Swept the pasted PR #19 review batch against the current branch.
|
||||
|
||||
Classification:
|
||||
- Already fixed on current branch: `src/core/services/immersion-tracker/__tests__/query.test.ts` cleanup rethrow, `src/core/services/ipc.ts` limit validation, `src/core/services/stats-server.ts` max-limit parsing and CORS removal, `src/main.ts` quit-path TDZ issue, `src/renderer/handlers/keyboard.ts` stats-toggle shortcut ordering/config usage, `stats/src/components/vocabulary/WordList.tsx`, `stats/src/hooks/useSessions.ts`, `stats/src/hooks/useTrends.ts` stale-error reset, `src/core/services/__tests__/stats-server.test.ts` kanji endpoint/readability notes, `src/core/services/stats-window.ts`, `stats/src/App.tsx`, `stats/src/components/layout/TabBar.tsx`, `stats/src/components/overview/QuickStats.tsx`, `stats/src/components/overview/WatchTimeChart.tsx`, `stats/src/components/sessions/SessionDetail.tsx`, `stats/src/components/sessions/SessionRow.tsx`, `stats/src/components/trends/DateRangeSelector.tsx`, `stats/src/components/vocabulary/KanjiBreakdown.tsx`, `stats/src/components/vocabulary/VocabularyTab.tsx`, `stats/src/hooks/useVocabulary.ts`, `stats/src/lib/api-client.ts`, `stats/src/types/stats.ts`.
|
||||
- Stale / obsolete against current architecture: `docs/superpowers/plans/2026-03-12-immersion-stats-page.md` path does not exist on this branch; `stats/src/components/trends/TrendsTab.tsx` / monthly-range comments describe older client-side aggregation code that is no longer present because trends now come from `getTrendsDashboard`.
|
||||
- Not warranted as written: `stats/src/lib/formatters.ts` no longer emits negative `Xd ago`; current code short-circuits future timestamps to `just now`, so the reported bug condition is gone even though the suggested wording differs.
|
||||
- Actionable and fixed now: `src/core/services/ipc.ts` no-tracker `statsGetOverview` fallback omitted required hint fields (`totalLookupCount`, `totalLookupHits`, `newWordsToday`, `newWordsThisWeek`). Added the missing fields in the fallback object and updated IPC tests to assert the full shape.
|
||||
|
||||
Verification:
|
||||
- `bun test src/core/services/ipc.test.ts`
|
||||
- `bun test src/core/services/ipc.test.ts --test-name-pattern "empty stats overview shape without a tracker|validates and clamps stats request limits"`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/ipc.ts src/core/services/ipc.test.ts`
|
||||
|
||||
Repo verifier note:
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/ipc.ts src/core/services/ipc.test.ts`
|
||||
- That verifier run captured a temporary `bun run typecheck` failure in `src/anki-integration.test.ts` and `src/core/services/__tests__/stats-server.test.ts`, but a fresh rerun after the follow-up validation no longer reproduces those diagnostics.
|
||||
- Fresh verification: `bun run typecheck` passes locally.
|
||||
- artifact dir from the earlier failed verifier snapshot: `.tmp/skill-verification/subminer-verify-20260317-234027-i6QJ3n`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
The larger pasted PR #19 review batch was not mostly new work on the current branch. After verifying each item against the live code, almost all were already fixed or stale. One additional item was still actionable: the no-tracker fallback returned by `statsGetOverview` in `src/core/services/ipc.ts` omitted required hint fields, which made the fallback shape inconsistent with the normal overview payload. That fallback is now fixed and covered by IPC tests.
|
||||
|
||||
Count-wise: the earlier open CodeRabbit service comments contributed 2 actionable fixes, and this larger pasted batch contributed 1 additional actionable fix on top of those.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: TASK-193
|
||||
title: Fix session chart event popup position drift
|
||||
status: Done
|
||||
assignee:
|
||||
- Codex
|
||||
created_date: '2026-03-17 23:55'
|
||||
updated_date: '2026-03-17 23:59'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
- bug
|
||||
milestone: m-1
|
||||
dependencies: []
|
||||
references:
|
||||
- stats/src/components/sessions/SessionDetail.tsx
|
||||
- stats/src/components/sessions/SessionEventOverlay.tsx
|
||||
- stats/src/lib/session-events.ts
|
||||
priority: medium
|
||||
ordinal: 105600
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Fix the session timeline event popup trigger positions so hover markers stay aligned with the underlying chart event lines across the full visible time range.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 Event popup triggers stay horizontally aligned with chart event lines from session start through session end.
|
||||
- [x] #2 Alignment logic uses the rendered chart plot area rather than guessed container percentages.
|
||||
- [x] #3 Regression coverage locks the marker-position projection math.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
|
||||
1. Add a failing regression test for marker-position projection with chart offsets.
|
||||
2. Capture the rendered plot box from Recharts and pass it into the overlay.
|
||||
3. Position overlay markers in plot-area pixels, rerun targeted stats verification, then record the result.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Outcome
|
||||
|
||||
<!-- SECTION:OUTCOME:BEGIN -->
|
||||
|
||||
Completed. Session event hover markers now read the actual Recharts plot-area offset and width, then project marker X positions into plot-area pixels instead of full-container percentages. That keeps popup triggers aligned with the underlying reference lines across long session timelines.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun test stats/src/lib/session-events.test.ts stats/src/lib/session-detail.test.tsx stats/src/components/sessions/SessionEventPopover.test.tsx`
|
||||
- `cd stats && bun run build`
|
||||
- `bun x prettier --check 'stats/src/components/sessions/SessionDetail.tsx' 'stats/src/components/sessions/SessionEventOverlay.tsx' 'stats/src/lib/session-events.ts' 'stats/src/lib/session-events.test.ts' 'backlog/tasks/task-193 - Fix-session-chart-event-popup-position-drift.md'`
|
||||
- `bun run typecheck:stats` still fails on pre-existing unrelated errors in `src/components/anime/AnilistSelector.tsx`, `src/components/library/LibraryTab.tsx`, `src/lib/reading-utils.test.ts`, `src/lib/reading-utils.ts`, `src/lib/vocabulary-tab.test.ts`, and `src/lib/yomitan-lookup.test.tsx`
|
||||
|
||||
<!-- SECTION:OUTCOME:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-194
|
||||
title: Redesign YouTube subtitle acquisition around download-first track selection
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-18 07:52'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/youtube/orchestrator.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/launcher/youtube/manual-subs.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.ts
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/youtube.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the current YouTube subtitle-generation-first flow with a download-first flow that enumerates available YouTube subtitle tracks, prompts for primary and secondary track selection before playback, downloads selected tracks into external subtitle files for mpv, and preserves generation as an explicit mode and as fallback behavior in auto mode. Keep the existing SubMiner tokenization and annotation pipeline as the downstream consumer of downloaded subtitle files.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Launcher and config expose YouTube subtitle acquisition modes `download`, `generate`, and `auto`, with `download` as the default for launcher YouTube playback.
|
||||
- [ ] #2 YouTube playback enumerates available subtitle tracks before mpv launch and presents a selection UI that supports primary and secondary subtitle choices.
|
||||
- [ ] #3 Selected YouTube subtitle tracks are downloaded to external subtitle files and loaded into mpv before playback starts when download mode succeeds.
|
||||
- [ ] #4 `auto` mode attempts download-first for the selected tracks and falls back to generation only when required tracks cannot be downloaded or download fails.
|
||||
- [ ] #5 `generate` mode preserves the existing whisper/AI generation path as an explicit opt-in behavior.
|
||||
- [ ] #6 Downloaded YouTube subtitle files integrate with the existing SubMiner subtitle/tokenization/annotation pipeline without regressing current overlay behavior.
|
||||
- [ ] #7 Tests cover mode selection, subtitle-track enumeration/selection flow, download-first success path, and fallback behavior for auto mode.
|
||||
- [ ] #8 User-facing config and launcher docs are updated to describe the new modes and default behavior.
|
||||
<!-- AC:END -->
|
||||
4
changes/2026-03-18-stats-cross-anime-words-table.md
Normal file
@@ -0,0 +1,4 @@
|
||||
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.
|
||||
4
changes/2026-03-18-stats-full-session-timelines.md
Normal file
@@ -0,0 +1,4 @@
|
||||
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.
|
||||
4
changes/2026-03-18-stats-mpv-args-passthrough.md
Normal file
@@ -0,0 +1,4 @@
|
||||
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.
|
||||
5
changes/2026-03-18-stats-yomitan-token-counts.md
Normal file
@@ -0,0 +1,5 @@
|
||||
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.
|
||||
4
changes/2026-03-18-subtitle-noise-filtering.md
Normal file
@@ -0,0 +1,4 @@
|
||||
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.
|
||||
@@ -319,6 +319,7 @@
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Word setting.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
@@ -347,7 +348,7 @@
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"decks": [], // Decks used for known-word cache scope. Supports one or more deck names.
|
||||
"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.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
@@ -498,13 +499,21 @@
|
||||
"queueCap": 1000, // In-memory write queue cap before overflow policy applies.
|
||||
"payloadCapBytes": 256, // Max JSON payload size per event before truncation.
|
||||
"maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
|
||||
"retentionMode": "preset", // Retention mode (`preset` uses preset values, `advanced` uses explicit values). Values: preset | advanced
|
||||
"retentionPreset": "balanced", // Retention preset when `retentionMode` is `preset`. Values: minimal | balanced | deep-history
|
||||
"retention": {
|
||||
"eventsDays": 7, // Raw event retention window in days.
|
||||
"telemetryDays": 30, // Telemetry retention window in days.
|
||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||
} // Retention setting.
|
||||
"eventsDays": 0, // Raw event retention window in days. Use 0 to keep all.
|
||||
"telemetryDays": 0, // Telemetry retention window in days. Use 0 to keep all.
|
||||
"sessionsDays": 0, // Session retention window in days. Use 0 to keep all.
|
||||
"dailyRollupsDays": 0, // Daily rollup retention window in days. Use 0 to keep all.
|
||||
"monthlyRollupsDays": 0, // Monthly rollup retention window in days. Use 0 to keep all.
|
||||
"vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable.
|
||||
}, // Retention setting.
|
||||
"lifetimeSummaries": {
|
||||
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
||||
"anime": true, // Maintain per-anime lifetime stats rows. Values: true | false
|
||||
"media": true // Maintain per-media lifetime stats rows. Values: true | false
|
||||
} // Lifetime summaries setting.
|
||||
}, // Enable/disable immersion tracking.
|
||||
|
||||
// ==========================================
|
||||
@@ -514,6 +523,7 @@
|
||||
// ==========================================
|
||||
"stats": {
|
||||
"toggleKey": "Backquote", // Key code to toggle the stats overlay.
|
||||
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
||||
"serverPort": 6969, // Port for the stats HTTP server.
|
||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||
|
||||
@@ -114,6 +114,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"fields": {
|
||||
"word": "Expression", // mined word / expression text
|
||||
"audio": "ExpressionAudio", // audio clip from the video
|
||||
"image": "Picture", // screenshot or animated clip
|
||||
"sentence": "Sentence", // subtitle text
|
||||
|
||||
@@ -733,6 +733,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"tags": ["SubMiner"],
|
||||
"deck": "Learning::Japanese",
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "ExpressionAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
@@ -797,6 +798,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `deck` | string | Anki deck to monitor for new cards |
|
||||
| `ankiConnect.knownWords.decks` | array of strings | Decks used for known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
|
||||
@@ -30,13 +30,37 @@ The same immersion data powers the stats dashboard.
|
||||
- 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.
|
||||
|
||||
Dashboard tabs:
|
||||
### Dashboard Tabs
|
||||
|
||||
- Overview: recent sessions, streak calendar, watch-time history, and a tracking snapshot with completed episodes/anime totals
|
||||
- Library: cover-art library, per-series progress, episode drill-down, and direct links into mined cards
|
||||
- Trends: watch time, sessions, words seen, and per-anime progress/pattern charts
|
||||
- Sessions: expandable session history with new-word activity, cumulative totals, and pause/seek/card markers
|
||||
- Vocabulary: top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons
|
||||
#### Overview
|
||||
|
||||
Recent sessions, streak calendar, watch-time history, and a tracking snapshot with completed episodes/anime totals.
|
||||
|
||||

|
||||
|
||||
#### Library
|
||||
|
||||
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
|
||||
|
||||

|
||||
|
||||
#### Trends
|
||||
|
||||
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping.
|
||||
|
||||

|
||||
|
||||
#### Sessions
|
||||
|
||||
Expandable session history with new-word activity, cumulative totals, and pause/seek/card markers.
|
||||
|
||||

|
||||
|
||||
#### Vocabulary
|
||||
|
||||
Top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||
|
||||

|
||||
|
||||
Stats server config lives under `stats`:
|
||||
|
||||
@@ -244,7 +268,7 @@ LIMIT ?;
|
||||
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
|
||||
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
|
||||
|
||||
### Schema (v12)
|
||||
### Schema (v4)
|
||||
|
||||
Core tables:
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Word setting.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
@@ -347,7 +348,7 @@
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"decks": [], // Decks used for known-word cache scope. Supports one or more deck names.
|
||||
"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.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
@@ -498,8 +499,8 @@
|
||||
"queueCap": 1000, // In-memory write queue cap before overflow policy applies.
|
||||
"payloadCapBytes": 256, // Max JSON payload size per event before truncation.
|
||||
"maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
|
||||
"retentionMode": "preset", // Retention mode to use for defaults. Values: preset | advanced
|
||||
"retentionPreset": "balanced", // Named preset when retentionMode is preset.
|
||||
"retentionMode": "preset", // Retention mode (`preset` uses preset values, `advanced` uses explicit values). Values: preset | advanced
|
||||
"retentionPreset": "balanced", // Retention preset when `retentionMode` is `preset`. Values: minimal | balanced | deep-history
|
||||
"retention": {
|
||||
"eventsDays": 0, // Raw event retention window in days. Use 0 to keep all.
|
||||
"telemetryDays": 0, // Telemetry retention window in days. Use 0 to keep all.
|
||||
@@ -509,10 +510,10 @@
|
||||
"vacuumIntervalDays": 0 // Minimum days between VACUUM runs. Use 0 to disable.
|
||||
}, // Retention setting.
|
||||
"lifetimeSummaries": {
|
||||
"global": true, // Keep lifetime global totals.
|
||||
"anime": true, // Keep lifetime per-anime totals.
|
||||
"media": true // Keep lifetime per-media totals.
|
||||
} // Lifetime summary setting.
|
||||
"global": true, // Maintain global lifetime stats rows. Values: true | false
|
||||
"anime": true, // Maintain per-anime lifetime stats rows. Values: true | false
|
||||
"media": true // Maintain per-media lifetime stats rows. Values: true | false
|
||||
} // Lifetime summaries setting.
|
||||
}, // Enable/disable immersion tracking.
|
||||
|
||||
// ==========================================
|
||||
@@ -522,6 +523,7 @@
|
||||
// ==========================================
|
||||
"stats": {
|
||||
"toggleKey": "Backquote", // Key code to toggle the stats overlay.
|
||||
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
|
||||
"serverPort": 6969, // Port for the stats HTTP server.
|
||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||
|
||||
BIN
docs-site/public/screenshots/anki-mining.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs-site/public/screenshots/annotations-key.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
docs-site/public/screenshots/annotations.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs-site/public/screenshots/stats-library.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs-site/public/screenshots/stats-overview.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
docs-site/public/screenshots/stats-sessions.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
docs-site/public/screenshots/stats-trends.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs-site/public/screenshots/stats-vocabulary.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs-site/public/screenshots/texthooker-empty.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
docs-site/public/screenshots/texthooker.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs-site/public/screenshots/yomitan-lookup.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -56,6 +56,7 @@ subminer ytsearch:"jp news" # Play first YouTube search result
|
||||
subminer --setup # Open first-run setup popup
|
||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||
|
||||
# Options
|
||||
subminer -T video.mkv # Disable texthooker server
|
||||
@@ -189,6 +190,8 @@ Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentional
|
||||
- `--secondary-sid=auto`
|
||||
- `--secondary-sub-visibility=no`
|
||||
|
||||
You can append additional MPV arguments with launcher `-a/--args`, for example `--args "--ao=alsa --volume=80"`.
|
||||
|
||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p <profile> ...`):
|
||||
|
||||
```ini
|
||||
|
||||
@@ -16,8 +16,8 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
||||
|
||||
- rollup-backed:
|
||||
- activity charts
|
||||
- cumulative watch/cards/words/sessions trends
|
||||
- per-anime watch/cards/words/episodes series
|
||||
- cumulative watch/cards/tokens/sessions trends
|
||||
- per-anime watch/cards/tokens/episodes series
|
||||
- session-metric-backed:
|
||||
- lookup trends
|
||||
- lookup rate trends
|
||||
@@ -25,6 +25,14 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
||||
- vocabulary-backed:
|
||||
- new-words trend
|
||||
|
||||
## Metric Semantics
|
||||
|
||||
- subtitle-count stats now use Yomitan merged-token counts as the source of truth
|
||||
- `tokensSeen` is the only active subtitle-count metric in tracker/session/rollup/query paths
|
||||
- no whitespace/CJK-character fallback remains in the live stats path
|
||||
|
||||
## Contract
|
||||
|
||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||
|
||||
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
||||
|
||||
@@ -134,6 +134,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
@@ -189,6 +190,7 @@ export function applyRootOptionsToArgs(
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void {
|
||||
program
|
||||
.option('-b, --backend <backend>', 'Display backend')
|
||||
.option('-d, --directory <dir>', 'Directory to browse')
|
||||
.option('-a, --args <args>', 'Pass arguments to MPV')
|
||||
.option('-r, --recursive', 'Search directories recursively')
|
||||
.option('-p, --profile <profile>', 'MPV profile')
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
@@ -103,6 +104,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
const optionsWithValue = new Set([
|
||||
'-b',
|
||||
'--backend',
|
||||
'-a',
|
||||
'--args',
|
||||
'-d',
|
||||
'--directory',
|
||||
'-p',
|
||||
|
||||
@@ -308,6 +308,85 @@ done
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards --args to mpv as parsed tokens', { 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=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\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(
|
||||
['--args', '--pause=yes --title="movie night"', videoPath],
|
||||
env,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
const argsFile = fs.readFileSync(mpvArgsPath, 'utf8');
|
||||
const forwardedArgs = argsFile
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(forwardedArgs.includes('--pause=yes'), true);
|
||||
assert.equal(forwardedArgs.includes('--title=movie night'), true);
|
||||
assert.equal(forwardedArgs.includes(videoPath), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -142,6 +142,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
|
||||
@@ -38,6 +38,79 @@ const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid
|
||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||
|
||||
export function parseMpvArgString(input: string): string[] {
|
||||
const chars = input;
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaping = false;
|
||||
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const ch = chars[i] || '';
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (ch === "'") {
|
||||
inSingleQuote = false;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (ch === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDoubleQuote = false;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
fail('Could not parse mpv args: trailing backslash');
|
||||
}
|
||||
if (inSingleQuote || inDoubleQuote) {
|
||||
fail('Could not parse mpv args: unmatched quote');
|
||||
}
|
||||
if (current) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function readTrackedDetachedMpvPid(): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, 'utf8').trim();
|
||||
@@ -463,6 +536,9 @@ export async function startMpv(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
|
||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||
log('info', args.logLevel, 'Applying URL playback options');
|
||||
@@ -859,6 +935,9 @@ export function launchMpvIdleDetached(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
mpvArgs.push('--idle=yes');
|
||||
mpvArgs.push(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
|
||||
@@ -23,6 +23,12 @@ test('parseArgs keeps all args after app verbatim', () => {
|
||||
assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']);
|
||||
});
|
||||
|
||||
test('parseArgs captures mpv args string', () => {
|
||||
const parsed = parseArgs(['--args', '--pause=yes --title="movie night"'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export interface Args {
|
||||
mpvIdle: boolean;
|
||||
mpvSocket: boolean;
|
||||
mpvStatus: boolean;
|
||||
mpvArgs: string;
|
||||
appPassthrough: boolean;
|
||||
appArgs: string[];
|
||||
jellyfinServer: string;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
## Highlights
|
||||
### Internal
|
||||
- Release: Seed the AUR checkout with the repo `.SRCINFO` template before rewriting metadata so tagged releases do not depend on prior AUR state.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
85
src/anki-field-config.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AnkiConnectConfig } from './types';
|
||||
|
||||
type NoteFieldValue = { value?: string } | string | null | undefined;
|
||||
|
||||
function normalizeFieldName(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function getConfiguredWordFieldName(config?: Pick<AnkiConnectConfig, 'fields'> | null): string {
|
||||
return normalizeFieldName(config?.fields?.word) ?? 'Expression';
|
||||
}
|
||||
|
||||
export function getConfiguredSentenceFieldName(
|
||||
config?: Pick<AnkiConnectConfig, 'fields'> | null,
|
||||
): string {
|
||||
return normalizeFieldName(config?.fields?.sentence) ?? 'Sentence';
|
||||
}
|
||||
|
||||
export function getConfiguredTranslationFieldName(
|
||||
config?: Pick<AnkiConnectConfig, 'fields'> | null,
|
||||
): string {
|
||||
return normalizeFieldName(config?.fields?.translation) ?? 'SelectionText';
|
||||
}
|
||||
|
||||
export function getConfiguredWordFieldCandidates(
|
||||
config?: Pick<AnkiConnectConfig, 'fields'> | null,
|
||||
): string[] {
|
||||
const preferred = getConfiguredWordFieldName(config);
|
||||
const candidates = [preferred, 'Expression', 'Word'];
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = candidate.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function coerceFieldValue(value: NoteFieldValue): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object' && typeof value.value === 'string') {
|
||||
return value.value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function stripAnkiFieldHtml(value: string): string {
|
||||
return value
|
||||
.replace(/\[sound:[^\]]+\]/gi, ' ')
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function getPreferredNoteFieldValue(
|
||||
fields: Record<string, NoteFieldValue> | null | undefined,
|
||||
preferredNames: string[],
|
||||
): string {
|
||||
if (!fields) return '';
|
||||
const entries = Object.entries(fields);
|
||||
for (const preferredName of preferredNames) {
|
||||
const preferredKey = preferredName.trim().toLowerCase();
|
||||
if (!preferredKey) continue;
|
||||
const entry = entries.find(([fieldName]) => fieldName.trim().toLowerCase() === preferredKey);
|
||||
if (!entry) continue;
|
||||
const cleaned = stripAnkiFieldHtml(coerceFieldValue(entry[1]));
|
||||
if (cleaned) return cleaned;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getPreferredWordValueFromExtractedFields(
|
||||
fields: Record<string, string>,
|
||||
config?: Pick<AnkiConnectConfig, 'fields'> | null,
|
||||
): string {
|
||||
for (const candidate of getConfiguredWordFieldCandidates(config)) {
|
||||
const value = fields[candidate.toLowerCase()]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -209,6 +209,27 @@ test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes',
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiIntegration resolves merged-away note ids to the kept note id', () => {
|
||||
const ctx = createIntegrationTestContext({
|
||||
stateDirPrefix: 'subminer-anki-integration-note-redirect-',
|
||||
});
|
||||
|
||||
try {
|
||||
const integrationWithInternals = ctx.integration as unknown as {
|
||||
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
||||
};
|
||||
integrationWithInternals.rememberMergedNoteIds(111, 222);
|
||||
integrationWithInternals.rememberMergedNoteIds(222, 333);
|
||||
|
||||
assert.equal(ctx.integration.resolveCurrentNoteId(111), 333);
|
||||
assert.equal(ctx.integration.resolveCurrentNoteId(222), 333);
|
||||
assert.equal(ctx.integration.resolveCurrentNoteId(333), 333);
|
||||
assert.equal(ctx.integration.resolveCurrentNoteId(444), 444);
|
||||
} finally {
|
||||
cleanupIntegrationTestContext(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiIntegration does not allocate proxy server when proxy transport is disabled', () => {
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
NPlusOneMatchMode,
|
||||
} from './types';
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from './config';
|
||||
import {
|
||||
getConfiguredWordFieldCandidates,
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from './anki-field-config';
|
||||
import { createLogger } from './logger';
|
||||
import {
|
||||
createUiFeedbackState,
|
||||
@@ -138,6 +143,7 @@ export class AnkiIntegration {
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -337,6 +343,7 @@ export class AnkiIntegration {
|
||||
private createFieldGroupingService(): FieldGroupingService {
|
||||
return new FieldGroupingService({
|
||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||
getConfig: () => this.config,
|
||||
isUpdateInProgress: () => this.updateInProgress,
|
||||
getDeck: () => this.config.deck,
|
||||
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) =>
|
||||
@@ -451,6 +458,9 @@ export class AnkiIntegration {
|
||||
removeTrackedNoteId: (noteId) => {
|
||||
this.previousNoteIds.delete(noteId);
|
||||
},
|
||||
rememberMergedNoteIds: (deletedNoteId, keptNoteId) => {
|
||||
this.rememberMergedNoteIds(deletedNoteId, keptNoteId);
|
||||
},
|
||||
showStatusNotification: (message) => this.showStatusNotification(message),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
showOsdNotification: (message) => this.showOsdNotification(message),
|
||||
@@ -972,6 +982,7 @@ export class AnkiIntegration {
|
||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
getDeck: () => this.config.deck,
|
||||
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
|
||||
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
|
||||
logInfo: (message) => {
|
||||
log.info(message);
|
||||
@@ -997,6 +1008,18 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
private getConfiguredWordFieldName(): string {
|
||||
return getConfiguredWordFieldName(this.config);
|
||||
}
|
||||
|
||||
private getConfiguredWordFieldCandidates(): string[] {
|
||||
return getConfiguredWordFieldCandidates(this.config);
|
||||
}
|
||||
|
||||
private getPreferredWordValue(fields: Record<string, string>): string {
|
||||
return getPreferredWordValueFromExtractedFields(fields, this.config);
|
||||
}
|
||||
|
||||
private async generateMediaForMerge(): Promise<{
|
||||
audioField?: string;
|
||||
audioValue?: string;
|
||||
@@ -1127,4 +1150,32 @@ export class AnkiIntegration {
|
||||
): void {
|
||||
this.recordCardsMinedCallback = callback;
|
||||
}
|
||||
|
||||
resolveCurrentNoteId(noteId: number): number {
|
||||
let resolved = noteId;
|
||||
const seen = new Set<number>();
|
||||
while (this.noteIdRedirects.has(resolved) && !seen.has(resolved)) {
|
||||
seen.add(resolved);
|
||||
resolved = this.noteIdRedirects.get(resolved)!;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private rememberMergedNoteIds(deletedNoteId: number, keptNoteId: number): void {
|
||||
const resolvedKeepNoteId = this.resolveCurrentNoteId(keptNoteId);
|
||||
const visited = new Set<number>([deletedNoteId]);
|
||||
let current = deletedNoteId;
|
||||
|
||||
while (true) {
|
||||
this.noteIdRedirects.set(current, resolvedKeepNoteId);
|
||||
const next = Array.from(this.noteIdRedirects.entries()).find(
|
||||
([, targetNoteId]) => targetNoteId === current,
|
||||
)?.[0];
|
||||
if (next === undefined || visited.has(next)) {
|
||||
break;
|
||||
}
|
||||
visited.add(next);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import {
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from '../anki-field-config';
|
||||
import { AiConfig, AnkiConnectConfig } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -201,7 +205,10 @@ export class CardCreationService {
|
||||
|
||||
const noteInfo = notesInfoResult[0]!;
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const expressionText = fields.expression || fields.word || '';
|
||||
const expressionText = getPreferredWordValueFromExtractedFields(
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
||||
|
||||
@@ -368,7 +375,10 @@ export class CardCreationService {
|
||||
|
||||
const noteInfo = notesInfoResult[0]!;
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
const expressionText = fields.expression || fields.word || '';
|
||||
const expressionText = getPreferredWordValueFromExtractedFields(
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
|
||||
const updatedFields: Record<string, string> = {};
|
||||
const errors: string[] = [];
|
||||
@@ -519,7 +529,7 @@ export class CardCreationService {
|
||||
|
||||
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
|
||||
fields.IsSentenceCard = 'x';
|
||||
fields.Expression = sentence;
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface DuplicateDetectionDeps {
|
||||
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>;
|
||||
notesInfo: (noteIds: number[]) => Promise<unknown>;
|
||||
getDeck: () => string | null | undefined;
|
||||
getWordFieldCandidates?: () => string[];
|
||||
resolveFieldName: (noteInfo: NoteInfo, preferredName: string) => string | null;
|
||||
logInfo?: (message: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
@@ -23,7 +24,12 @@ export async function findDuplicateNote(
|
||||
noteInfo: NoteInfo,
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
const sourceCandidates = getDuplicateSourceCandidates(noteInfo, expression);
|
||||
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
|
||||
const sourceCandidates = getDuplicateSourceCandidates(
|
||||
noteInfo,
|
||||
expression,
|
||||
configuredWordFieldCandidates,
|
||||
);
|
||||
if (sourceCandidates.length === 0) return null;
|
||||
deps.logInfo?.(
|
||||
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
|
||||
@@ -81,6 +87,7 @@ export async function findDuplicateNote(
|
||||
noteIds,
|
||||
excludeNoteId,
|
||||
sourceCandidates.map((candidate) => candidate.value),
|
||||
configuredWordFieldCandidates,
|
||||
deps,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -93,6 +100,7 @@ function findFirstExactDuplicateNoteId(
|
||||
candidateNoteIds: Iterable<number>,
|
||||
excludeNoteId: number,
|
||||
sourceValues: string[],
|
||||
candidateFieldNames: string[],
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
|
||||
@@ -116,7 +124,6 @@ function findFirstExactDuplicateNoteId(
|
||||
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
|
||||
const notesInfo = notesInfoResult as NoteInfo[];
|
||||
for (const noteInfo of notesInfo) {
|
||||
const candidateFieldNames = ['word', 'expression'];
|
||||
for (const candidateFieldName of candidateFieldNames) {
|
||||
const resolvedField = deps.resolveFieldName(noteInfo, candidateFieldName);
|
||||
if (!resolvedField) continue;
|
||||
@@ -150,13 +157,15 @@ function getDuplicateCandidateFieldNames(fieldName: string): string[] {
|
||||
function getDuplicateSourceCandidates(
|
||||
noteInfo: NoteInfo,
|
||||
fallbackExpression: string,
|
||||
configuredFieldNames: string[],
|
||||
): Array<{ fieldName: string; value: string }> {
|
||||
const candidates: Array<{ fieldName: string; value: string }> = [];
|
||||
const dedupeKey = new Set<string>();
|
||||
const configuredFieldNameSet = new Set(configuredFieldNames.map((name) => name.toLowerCase()));
|
||||
|
||||
for (const fieldName of Object.keys(noteInfo.fields)) {
|
||||
const lower = fieldName.toLowerCase();
|
||||
if (lower !== 'word' && lower !== 'expression') continue;
|
||||
if (!configuredFieldNameSet.has(lower)) continue;
|
||||
const value = noteInfo.fields[fieldName]?.value?.trim() ?? '';
|
||||
if (!value) continue;
|
||||
const key = `${lower}:${normalizeDuplicateValue(value)}`;
|
||||
@@ -167,9 +176,10 @@ function getDuplicateSourceCandidates(
|
||||
|
||||
const trimmedFallback = fallbackExpression.trim();
|
||||
if (trimmedFallback.length > 0) {
|
||||
const fallbackKey = `expression:${normalizeDuplicateValue(trimmedFallback)}`;
|
||||
const fallbackFieldName = configuredFieldNames[0]?.toLowerCase() || 'expression';
|
||||
const fallbackKey = `${fallbackFieldName}:${normalizeDuplicateValue(trimmedFallback)}`;
|
||||
if (!dedupeKey.has(fallbackKey)) {
|
||||
candidates.push({ fieldName: 'expression', value: trimmedFallback });
|
||||
candidates.push({ fieldName: configuredFieldNames[0] || 'Expression', value: trimmedFallback });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnkiConnectConfig } from '../types';
|
||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||
|
||||
interface FieldGroupingMergeMedia {
|
||||
audioField?: string;
|
||||
@@ -77,6 +78,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
includeGeneratedMedia: boolean,
|
||||
): Promise<Record<string, string>> {
|
||||
const config = this.deps.getConfig();
|
||||
const configuredWordField = getConfiguredWordFieldName(config);
|
||||
const groupableFields = this.getGroupableFieldNames();
|
||||
const keepFieldNames = Object.keys(keepNoteInfo.fields);
|
||||
const sourceFields: Record<string, string> = {};
|
||||
@@ -98,11 +100,17 @@ export class FieldGroupingMergeCollaborator {
|
||||
if (!sourceFields['Sentence'] && sourceFields['SentenceFurigana']) {
|
||||
sourceFields['Sentence'] = sourceFields['SentenceFurigana'];
|
||||
}
|
||||
if (!sourceFields['Expression'] && sourceFields['Word']) {
|
||||
sourceFields['Expression'] = sourceFields['Word'];
|
||||
if (!sourceFields[configuredWordField] && sourceFields['Expression']) {
|
||||
sourceFields[configuredWordField] = sourceFields['Expression'];
|
||||
}
|
||||
if (!sourceFields['Word'] && sourceFields['Expression']) {
|
||||
sourceFields['Word'] = sourceFields['Expression'];
|
||||
if (!sourceFields[configuredWordField] && sourceFields['Word']) {
|
||||
sourceFields[configuredWordField] = sourceFields['Word'];
|
||||
}
|
||||
if (!sourceFields['Expression'] && sourceFields[configuredWordField]) {
|
||||
sourceFields['Expression'] = sourceFields[configuredWordField];
|
||||
}
|
||||
if (!sourceFields['Word'] && sourceFields[configuredWordField]) {
|
||||
sourceFields['Word'] = sourceFields[configuredWordField];
|
||||
}
|
||||
if (!sourceFields['SentenceAudio'] && sourceFields['ExpressionAudio']) {
|
||||
sourceFields['SentenceAudio'] = sourceFields['ExpressionAudio'];
|
||||
@@ -148,6 +156,7 @@ export class FieldGroupingMergeCollaborator {
|
||||
const keepFieldNormalized = keepFieldName.toLowerCase();
|
||||
if (
|
||||
keepFieldNormalized === 'expression' ||
|
||||
keepFieldNormalized === configuredWordField.toLowerCase() ||
|
||||
keepFieldNormalized === 'expressionfurigana' ||
|
||||
keepFieldNormalized === 'expressionreading' ||
|
||||
keepFieldNormalized === 'expressionaudio'
|
||||
|
||||
@@ -24,6 +24,7 @@ function createWorkflowHarness() {
|
||||
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
||||
const deleted: number[][] = [];
|
||||
const statuses: string[] = [];
|
||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||
const mergeCalls: Array<{
|
||||
keepNoteId: number;
|
||||
deleteNoteId: number;
|
||||
@@ -99,6 +100,9 @@ function createWorkflowHarness() {
|
||||
hasFieldValue: (_noteInfo: NoteInfo, _field?: string) => false,
|
||||
addConfiguredTagsToNote: async () => undefined,
|
||||
removeTrackedNoteId: () => undefined,
|
||||
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => {
|
||||
rememberedMerges.push({ deletedNoteId, keptNoteId });
|
||||
},
|
||||
showStatusNotification: (message: string) => {
|
||||
statuses.push(message);
|
||||
},
|
||||
@@ -113,6 +117,7 @@ function createWorkflowHarness() {
|
||||
workflow: new FieldGroupingWorkflow(deps),
|
||||
updates,
|
||||
deleted,
|
||||
rememberedMerges,
|
||||
statuses,
|
||||
mergeCalls,
|
||||
setManualChoice: (choice: typeof manualChoice) => {
|
||||
@@ -136,6 +141,7 @@ test('FieldGroupingWorkflow auto merge updates keep note and deletes duplicate b
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.equal(harness.updates[0]?.noteId, 1);
|
||||
assert.deepEqual(harness.deleted, [[2]]);
|
||||
assert.deepEqual(harness.rememberedMerges, [{ deletedNoteId: 2, keptNoteId: 1 }]);
|
||||
assert.equal(harness.statuses.length, 1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KikuDuplicateCardInfo, KikuFieldGroupingChoice } from '../types';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
|
||||
export interface FieldGroupingWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
@@ -13,6 +14,7 @@ export interface FieldGroupingWorkflowDeps {
|
||||
};
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
word?: string;
|
||||
audio?: string;
|
||||
image?: string;
|
||||
};
|
||||
@@ -48,6 +50,7 @@ export interface FieldGroupingWorkflowDeps {
|
||||
hasFieldValue: (noteInfo: FieldGroupingWorkflowNoteInfo, preferredFieldName?: string) => boolean;
|
||||
addConfiguredTagsToNote: (noteId: number) => Promise<void>;
|
||||
removeTrackedNoteId: (noteId: number) => void;
|
||||
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
||||
showStatusNotification: (message: string) => void;
|
||||
showNotification: (noteId: number, label: string | number) => Promise<void>;
|
||||
showOsdNotification: (message: string) => void;
|
||||
@@ -156,6 +159,7 @@ export class FieldGroupingWorkflow {
|
||||
if (deleteDuplicate) {
|
||||
await this.deps.client.deleteNotes([deleteNoteId]);
|
||||
this.deps.removeTrackedNoteId(deleteNoteId);
|
||||
this.deps.rememberMergedNoteIds(deleteNoteId, keepNoteId);
|
||||
}
|
||||
|
||||
this.deps.logInfo('Merged duplicate card:', expression, 'into note:', keepNoteId);
|
||||
@@ -176,7 +180,8 @@ export class FieldGroupingWorkflow {
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
return {
|
||||
noteId: noteInfo.noteId,
|
||||
expression: fields.expression || fields.word || fallbackExpression,
|
||||
expression:
|
||||
getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig()) || fallbackExpression,
|
||||
sentencePreview: this.deps.truncateSentence(
|
||||
fields[(sentenceCardConfig.sentenceField || 'sentence').toLowerCase()] ||
|
||||
(isOriginal ? '' : this.deps.getCurrentSubtitleText() || ''),
|
||||
@@ -191,7 +196,7 @@ export class FieldGroupingWorkflow {
|
||||
|
||||
private getExpression(noteInfo: FieldGroupingWorkflowNoteInfo): string {
|
||||
const fields = this.deps.extractFields(noteInfo.fields);
|
||||
return fields.expression || fields.word || '';
|
||||
return getPreferredWordValueFromExtractedFields(fields, this.deps.getConfig());
|
||||
}
|
||||
|
||||
private async resolveFieldGroupingCallback(): Promise<
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { KikuMergePreviewResponse } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
|
||||
const log = createLogger('anki').child('integration.field-grouping');
|
||||
|
||||
@@ -9,6 +10,11 @@ interface FieldGroupingNoteInfo {
|
||||
}
|
||||
|
||||
interface FieldGroupingDeps {
|
||||
getConfig: () => {
|
||||
fields?: {
|
||||
word?: string;
|
||||
};
|
||||
};
|
||||
getEffectiveSentenceCardConfig: () => {
|
||||
model?: string;
|
||||
sentenceField: string;
|
||||
@@ -102,7 +108,10 @@ export class FieldGroupingService {
|
||||
}
|
||||
const noteInfoBeforeUpdate = notesInfo[0]!;
|
||||
const fields = this.deps.extractFields(noteInfoBeforeUpdate.fields);
|
||||
const expressionText = fields.expression || fields.word || '';
|
||||
const expressionText = getPreferredWordValueFromExtractedFields(
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
if (!expressionText) {
|
||||
this.deps.showOsdNotification('No expression/word field found');
|
||||
return;
|
||||
|
||||