mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d2f9a91d39
|
|||
|
7cc9f72fc9
|
|||
|
de5b1b5d10
|
|||
| b98d4d65c7 | |||
|
046e74ea91
|
|||
| 8bf847503d | |||
|
47b5903392
|
|||
| bf85554d1e | |||
|
d74c7e1235
|
|||
| 57ddd19953 | |||
| ee25536d90 | |||
| 7b0fbdf254 | |||
| 2fefc83e3f | |||
| dbdf578c68 | |||
| 441ecf3c04 | |||
|
a0dde4ee3e
|
|||
| fe4dacc1e7 | |||
| b08cd0db35 | |||
| bffb1c5982 | |||
| 5b8848518a | |||
|
176edd67f1
|
|||
| 4d65dec340 | |||
|
6607c333bc
|
|||
|
b2bbf1ae12
|
|||
| b204d4dd6e | |||
| 89ed675935 | |||
|
1995200e76
|
|||
|
e876e483d6
|
|||
| 6d2a72e13b | |||
| 0d7084c8aa | |||
|
95e0abc7b7
|
|||
|
d9155ceacb
|
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "subminer-local",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "SubMiner Local"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "subminer-workflow",
|
|
||||||
"source": {
|
|
||||||
"source": "local",
|
|
||||||
"path": "./plugins/subminer-workflow"
|
|
||||||
},
|
|
||||||
"policy": {
|
|
||||||
"installation": "AVAILABLE",
|
|
||||||
"authentication": "ON_INSTALL"
|
|
||||||
},
|
|
||||||
"category": "Productivity"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,45 @@
|
|||||||
---
|
---
|
||||||
name: 'subminer-change-verification'
|
name: subminer-change-verification
|
||||||
description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.'
|
description: Verify SubMiner changes with repo-native cheap-first test lanes. Use after code, config, launcher, plugin, runtime, stats, documentation, or workflow changes; do not use for read-only questions.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Compatibility Shim
|
# SubMiner Change Verification
|
||||||
|
|
||||||
Canonical source:
|
Verify the behavior claimed by a change without running unrelated expensive checks by default.
|
||||||
|
|
||||||
- `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md`
|
## Workflow
|
||||||
|
|
||||||
Canonical helper scripts:
|
1. Inspect the requested scope and changed paths with `git status --short` and `git diff`.
|
||||||
|
2. Read `docs/workflow/verification.md` as the source of truth for maintained lanes.
|
||||||
|
3. Run the cheapest lane or lanes that cover the changed behavior.
|
||||||
|
4. Escalate to the full handoff gate only for substantial or cross-boundary changes.
|
||||||
|
5. Report exact commands, results, skipped checks, blockers, and remaining risk.
|
||||||
|
|
||||||
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
|
Do not use hidden wrapper commands. Verification commands are owned by `package.json` and the workflow documentation.
|
||||||
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
|
|
||||||
|
|
||||||
When this shim is invoked:
|
## Lane Selection
|
||||||
|
|
||||||
1. Read the canonical plugin-owned skill.
|
- Internal docs, `AGENTS.md`, or `.agents/skills/**`: `bun run test:docs:kb`
|
||||||
2. Follow the plugin-owned skill as the source of truth.
|
- User-facing `docs-site/**`: `bun run docs:test`, then `bun run docs:build`
|
||||||
3. Use the wrapper scripts in this shim directory only for compatibility with existing commands and docs.
|
- Config/schema/defaults: `bun run test:config`
|
||||||
4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead.
|
- If defaults or templates changed, also run `bun run generate:config-example` and `bun run verify:config-example`.
|
||||||
|
- General TypeScript source: `bun run typecheck`, then `bun run test:fast`
|
||||||
|
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||||
|
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||||
|
- Stats dashboard: `bun run test:stats`
|
||||||
|
- Build/release scripts: `bun run test:scripts`
|
||||||
|
|
||||||
|
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||||
|
|
||||||
|
## Runtime Escalation
|
||||||
|
|
||||||
|
Real runtime checks are required when the claim depends on actual Electron, mpv, overlay, focus, window tracking, launch, or socket behavior. Run the relevant application flow when the environment supports it. Otherwise, report the missing runtime dependency and do not present cheaper checks as authoritative runtime validation.
|
||||||
|
|
||||||
|
## Pre-Handoff Checks
|
||||||
|
|
||||||
|
Before handoff, reconcile both questions:
|
||||||
|
|
||||||
|
1. Do behavior, defaults, flags, shortcuts, ports, APIs, architecture, or workflow changes require documentation updates?
|
||||||
|
2. Does the change require a current-outcome fragment under `changes/` according to `changes/README.md`?
|
||||||
|
|
||||||
|
Complete required updates before handoff or report the blocker.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
|
|
||||||
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh"
|
|
||||||
|
|
||||||
if [[ ! -x "$TARGET" ]]; then
|
|
||||||
echo "Missing canonical script: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$TARGET" "$@"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
|
|
||||||
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh"
|
|
||||||
|
|
||||||
if [[ ! -x "$TARGET" ]]; then
|
|
||||||
echo "Missing canonical script: $TARGET" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$TARGET" "$@"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: 'subminer-scrum-master'
|
|
||||||
description: 'Compatibility shim. Canonical SubMiner scrum-master workflow now lives in the repo-local subminer-workflow plugin.'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Compatibility Shim
|
|
||||||
|
|
||||||
Canonical source:
|
|
||||||
|
|
||||||
- `plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md`
|
|
||||||
|
|
||||||
When this shim is invoked:
|
|
||||||
|
|
||||||
1. Read the canonical plugin-owned skill.
|
|
||||||
2. Follow the plugin-owned skill as the source of truth.
|
|
||||||
3. Do not duplicate workflow changes here; update the plugin-owned skill instead.
|
|
||||||
|
|
||||||
This shim exists so existing repo references and prompts keep resolving during the migration to the repo-local plugin workflow.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# AGENTS.MD
|
# AGENTS.md
|
||||||
|
|
||||||
## Internal Docs
|
## Internal Docs
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Start here, then leave this file.
|
|||||||
|
|
||||||
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
|
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
|
||||||
|
|
||||||
`CLAUDE.md` is a symlink to this file — there is one project instruction file, not two.
|
`CLAUDE.md` is a symlink to this file; there is one project instruction file, not two.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -25,8 +25,9 @@ Start here, then leave this file.
|
|||||||
|
|
||||||
## Build / Test
|
## Build / Test
|
||||||
|
|
||||||
- Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
|
- Runtime/package manager: Bun; use the version pinned by `package.json`.
|
||||||
- Default handoff gate:
|
- Follow [`docs/workflow/verification.md`](./docs/workflow/verification.md) and start with the cheapest sufficient lane.
|
||||||
|
- Full handoff gate for substantial changes:
|
||||||
`bun run typecheck`
|
`bun run typecheck`
|
||||||
`bun run test:fast`
|
`bun run test:fast`
|
||||||
`bun run test:env`
|
`bun run test:env`
|
||||||
@@ -44,13 +45,15 @@ Start here, then leave this file.
|
|||||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||||
|
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||||
|
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
||||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||||
|
|
||||||
## Docs Upkeep
|
## Docs Upkeep
|
||||||
|
|
||||||
- Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change.
|
- Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change.
|
||||||
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it — and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
|
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it, and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
|
||||||
- Trigger map (touch left → update right):
|
- Trigger map (touch left → update right):
|
||||||
- `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default
|
- `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default
|
||||||
- shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
|
- shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
|
||||||
@@ -71,16 +74,10 @@ Start here, then leave this file.
|
|||||||
|
|
||||||
## Release / PR Notes
|
## Release / PR Notes
|
||||||
|
|
||||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
|
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md`. Format and rules live in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out).
|
||||||
- User-visible docs changes get a `type: docs` fragment
|
- User-visible docs changes get a `type: docs` fragment
|
||||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||||
- PR review helpers:
|
- PR review helpers:
|
||||||
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
|
- `gh pr view --json number,title --jq '"PR #\\(.number): \\(.title)"'`
|
||||||
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
|
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
|
||||||
|
- For CI debugging, inspect runs with `gh run list/view`; rerun or fix only within the requested scope.
|
||||||
## Runtime Notes
|
|
||||||
|
|
||||||
- Use Codex background for long jobs; tmux only when persistence/interaction is required
|
|
||||||
- CI red: `gh run list/view`, rerun, fix, repeat until green
|
|
||||||
- TypeScript: keep files small; follow existing patterns
|
|
||||||
- Only Swift is the `scripts/get-mpv-window-macos.swift` helper (macOS mpv window detection); validate via `bun test scripts/get-mpv-window-macos.test.ts`
|
|
||||||
|
|||||||
@@ -1,5 +1,92 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.3 (2026-08-13)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
|
||||||
|
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
|
||||||
|
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
|
||||||
|
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
|
||||||
|
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
|
||||||
|
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||||
|
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
||||||
|
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
||||||
|
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
||||||
|
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
||||||
|
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
||||||
|
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
||||||
|
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||||
|
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
||||||
|
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
||||||
|
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
||||||
|
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
||||||
|
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
||||||
|
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
|
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
||||||
|
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
||||||
|
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
|||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -90,6 +90,14 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<td><b>Jimaku</b></td>
|
<td><b>Jimaku</b></td>
|
||||||
<td>Search and download Japanese subtitles</td>
|
<td>Search and download Japanese subtitles</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>TsukiHime</b></td>
|
||||||
|
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><b>AniSkip</b></td>
|
||||||
|
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
@@ -110,18 +118,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ----------- | ---------------------------------------- |
|
| -------------------- | ---------------- | -------------------------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| fzf / rofi | Optional | Video picker in the launcher |
|
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| guessit | Optional | Better anime title and episode detection |
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
|
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -138,9 +147,23 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
|||||||
brew install mpv ffmpeg mecab mecab-ipadic
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
**Windows:**
|
||||||
|
|
||||||
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
```powershell
|
||||||
|
winget install shinchiro.mpv
|
||||||
|
winget install Gyan.FFmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
||||||
|
|
||||||
|
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scoop bucket add extras
|
||||||
|
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -166,6 +189,11 @@ paru -S subminer-bin
|
|||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,16 +36,18 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.1",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -266,7 +268,7 @@
|
|||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
@@ -496,7 +498,7 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||||
|
|
||||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
@@ -714,7 +716,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
@@ -754,8 +756,6 @@
|
|||||||
|
|
||||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
@@ -808,8 +808,6 @@
|
|||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
|
|
||||||
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: Anki media
|
||||||
|
|
||||||
|
- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: changed
|
|
||||||
area: shortcuts
|
|
||||||
|
|
||||||
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
|
||||||
|
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anki
|
|
||||||
|
|
||||||
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: app
|
|
||||||
|
|
||||||
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: character dictionary
|
|
||||||
|
|
||||||
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
|
|
||||||
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added a golden-file regression corpus for the tokenizer/annotation pipeline: recorded Yomitan backend responses and MeCab tokens replay through the real tokenizeSubtitle pipeline in bun tests without Electron or dictionaries.
|
|
||||||
- Added `record-tokenizer-fixture:electron` script to capture new fixtures from a live Yomitan/MeCab session, with flags for known words, JLPT levels, and annotation toggles.
|
|
||||||
- Seeded eleven fixtures covering the #147–#156 regression classes (grammar-helper suppression, lexical くれる, kanji non-independent nouns, N+1 targeting, reading collisions, unparsed runs, ordinal/honorific prefixes).
|
|
||||||
- Added `compare-yomitan-api:electron` script that diffs SubMiner tokenization against a stock Yomitan instance via the yomitan-api bridge (segmentation, readings, headword forms).
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
|
||||||
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Consolidated renderer modal state handling into a descriptor registry.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: release
|
|
||||||
|
|
||||||
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
|
|
||||||
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it.
|
||||||
|
- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window.
|
||||||
|
- If the delete worker crashes, the delete now retries on the current thread instead of failing.
|
||||||
|
- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window.
|
||||||
|
- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead.
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: sync
|
|
||||||
|
|
||||||
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
|
|
||||||
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
|
|
||||||
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
|
|
||||||
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
|
|
||||||
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: overlay
|
|
||||||
|
|
||||||
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
|
|
||||||
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
|
|
||||||
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
|
|
||||||
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
|
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
The update flow:
|
||||||
|
|
||||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
||||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||||
|
|
||||||
@@ -69,6 +69,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
|
|||||||
|
|
||||||
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
||||||
|
|
||||||
|
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
||||||
@@ -93,15 +95,15 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||||
|
|
||||||
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|
||||||
|
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
## Field Grouping (Kiku)
|
||||||
|
|
||||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -313,11 +328,11 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
|
|
||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | --------------------------------------------- |
|
||||||
| Sentence | Both cards' sentences kept as grouped entries |
|
| Sentence | Both cards' sentences kept as grouped entries |
|
||||||
| Audio | Both cards' `[sound:...]` entries kept |
|
| Audio | Both cards' `[sound:...]` entries kept |
|
||||||
| Image | Both cards' images kept |
|
| Image | Both cards' images kept |
|
||||||
|
|
||||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||||
# character dictionary, playlist browser, subtitle sidebar,
|
# changelog, character dictionary, playlist browser, subtitle
|
||||||
# YouTube track picker, controller config/debug/select)
|
# sidebar, YouTube track picker, controller config/debug/select)
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
settings/ # Settings window UI (model, controls, markup)
|
settings/ # Settings window UI (model, controls, markup)
|
||||||
types/ # Domain type modules (anki, config, integrations, ...)
|
types/ # Domain type modules (anki, config, integrations, ...)
|
||||||
|
|||||||
+94
-2
@@ -1,6 +1,98 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.19.3 (2026-08-13)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
|
||||||
|
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
|
||||||
|
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
|
||||||
|
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
|
||||||
|
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
|
||||||
|
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||||
|
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
||||||
|
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
||||||
|
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
||||||
|
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
||||||
|
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
||||||
|
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
||||||
|
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||||
|
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
||||||
|
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
||||||
|
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
||||||
|
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
||||||
|
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
||||||
|
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
|
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
||||||
|
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
||||||
|
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Previous Versions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.18.x</summary>
|
||||||
|
|
||||||
|
<h2>v0.18.0 (2026-07-10)</h2>
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||||
@@ -31,7 +123,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Previous Versions
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.17.x</summary>
|
<summary>v0.17.x</summary>
|
||||||
|
|||||||
@@ -223,7 +223,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||||
|
|
||||||
|
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
|
||||||
|
|
||||||
## Managing Loaded Entries
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
+74
-56
@@ -398,30 +398,30 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
||||||
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
||||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
|
||||||
Subtitle CSS custom properties:
|
Subtitle CSS custom properties:
|
||||||
|
|
||||||
@@ -555,11 +555,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||||
|
|
||||||
@@ -1043,7 +1043,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
|
},
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1077,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
|
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
||||||
|
|
||||||
|
### Word Card Type
|
||||||
|
|
||||||
|
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||||
|
|
||||||
|
| Value | Flag set |
|
||||||
|
| ----------------------------- | ----------------------- |
|
||||||
|
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||||
|
| `click` | `IsClickCard` |
|
||||||
|
| `sentence` | `IsSentenceCard` |
|
||||||
|
| `audio` | `IsAudioCard` |
|
||||||
|
| `none` | none; flags left as-is |
|
||||||
|
|
||||||
|
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1167,10 +1185,10 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
||||||
|
|
||||||
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
|
|||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
|
|
||||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
||||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1228,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||||
|
|
||||||
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
||||||
|
|
||||||
@@ -1539,18 +1557,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
||||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||||
|
|
||||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ The same immersion data powers the stats dashboard.
|
|||||||
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
|
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
|
||||||
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
|
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
|
||||||
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
||||||
- Maintenance commands: 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; `subminer stats cleanup -l` repairs lifetime summary tables. `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
- Maintenance commands: 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; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||||
|
|
||||||
### Dashboard Tabs
|
### Dashboard Tabs
|
||||||
@@ -57,6 +57,8 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
|
|||||||
|
|
||||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||||
|
|
||||||
|
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Trends
|
#### Trends
|
||||||
@@ -123,6 +125,34 @@ Secondary subtitle text (typically English translations) is stored alongside pri
|
|||||||
|
|
||||||
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
||||||
|
|
||||||
|
### Repeated Line Cleanup
|
||||||
|
|
||||||
|
Karaoke openings and animated signs are authored as one subtitle event per animation frame, all carrying the same text. Playback reports every one of those frames, so a single OP lyric could be recorded hundreds of times and dominate "Top Repeated Words".
|
||||||
|
|
||||||
|
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
|
||||||
|
|
||||||
|
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
|
||||||
|
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
|
||||||
|
|
||||||
|
For stats recorded before this, the Vocabulary tab toolbar has a **Duplicates** button:
|
||||||
|
|
||||||
|
- Pick how far back to look (7 days, 30 days, 90 days, 1 year, or all time). A narrower window does less work and keeps older history untouched.
|
||||||
|
- **Scan** reports the bursts found, the lines they added, and the word and kanji counts they inflated, without writing anything.
|
||||||
|
- **Clean Up** applies exactly what the scan reported: each run collapses to its first line (extended to cover the run), and the removed lines' word and kanji occurrences are subtracted from the vocabulary aggregates.
|
||||||
|
|
||||||
|
The same thing runs from the terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
|
||||||
|
subminer stats cleanup --duplicate-lines --lookback-days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
`--duplicate-lines` (short: `-d`) picks the cleanup mode, so it cannot be combined with `--vocab` or `--lifetime`, and `--dry-run` and `--lookback-days <days>` only apply to it. Omitting `--lookback-days` scans all history; the value must be at least one day.
|
||||||
|
|
||||||
|
The cleanup chains runs per line of text, so interleaved dual-line karaoke collapses each of its lines. It also removes the short residue the live rule stores before a run is long enough to recognize: a run one frame short of the usual minimum qualifies when every event is under the strict 0.1s bound.
|
||||||
|
|
||||||
|
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
|
||||||
|
|
||||||
## Retention Defaults
|
## Retention Defaults
|
||||||
|
|
||||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||||
|
|||||||
@@ -183,12 +183,8 @@ Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -ve
|
|||||||
|
|
||||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
||||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
||||||
3. Press `Win`, type **Edit the system environment variables**, and open it.
|
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
|
||||||
4. Click **Environment Variables…**
|
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
|
||||||
5. Under **User variables**, select **Path** and click **Edit…**
|
|
||||||
6. Click **New** and paste the folder containing `mpv.exe`. Click **New** again and paste the folder containing `ffmpeg.exe`.
|
|
||||||
7. Click **OK** on all three dialogs.
|
|
||||||
8. Close and reopen your terminal (PATH changes only apply to newly opened windows), then check:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
mpv --version
|
mpv --version
|
||||||
@@ -197,8 +193,10 @@ ffmpeg -version
|
|||||||
|
|
||||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
||||||
|
|
||||||
::: tip Don't want to touch PATH at all?
|
::: tip mpv can skip PATH, ffmpeg cannot
|
||||||
mpv is the only hard requirement, and you can point SubMiner straight at it instead: leave `PATH` alone and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup. ffmpeg still needs to be on `PATH` for card audio and screenshots.
|
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
|
||||||
|
|
||||||
|
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ subminer stats -b # start background stats daemon
|
|||||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||||
|
| `subminer stats cleanup -d` | Collapse repeated lines from typeset subs (`--dry-run`, `--lookback-days <n>`) |
|
||||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||||
| `subminer settings` | Open the SubMiner settings window |
|
| `subminer settings` | Open the SubMiner settings window |
|
||||||
|
|||||||
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
|
|
||||||
1. Open the subsync modal from the overlay.
|
1. Open the subsync modal from the overlay.
|
||||||
2. Select the sync engine (alass or ffsubsync).
|
2. Select the sync engine (alass or ffsubsync).
|
||||||
3. For alass, select a reference subtitle track from the video.
|
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
||||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
||||||
|
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
||||||
|
|
||||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
||||||
|
|
||||||
|
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||||
|
|
||||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku setting.
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||||
|
} // Lapis kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
|
|||||||
|
|
||||||
**Audio or screenshot generation hangs**
|
**Audio or screenshot generation hangs**
|
||||||
|
|
||||||
Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try:
|
Audio extraction has a 2-minute timeout. SubMiner also limits FFmpeg probing when mpv provides the selected audio stream, which avoids scanning unrelated subtitle and font-attachment streams in large MKV files. Screenshots retain a 30-second timeout, and animated AVIF uses 60 seconds.
|
||||||
|
|
||||||
|
If your video file is on a slow or unresponsive network mount, generation may still time out. Try:
|
||||||
|
|
||||||
- Using a local copy of the video file.
|
- Using a local copy of the video file.
|
||||||
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
|
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
|
||||||
@@ -227,7 +229,7 @@ Install ffsubsync or configure the path:
|
|||||||
|
|
||||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||||
|
|
||||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||||
- Try running the sync tool manually to see detailed error output.
|
- Try running the sync tool manually to see detailed error output.
|
||||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||||
@@ -405,8 +407,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
|
|||||||
|
|
||||||
SubMiner handles this automatically:
|
SubMiner handles this automatically:
|
||||||
|
|
||||||
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
- It launches its own window under XWayland (it sets `--ozone-platform=x11`).
|
||||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
|
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
||||||
|
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
|
||||||
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
||||||
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
||||||
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
||||||
@@ -420,7 +423,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
|
|||||||
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
||||||
|
|
||||||
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
||||||
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
|
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
|
||||||
|
|
||||||
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -95,6 +95,8 @@ subminer texthooker # Texthooker-only mode (-o also opens the brow
|
|||||||
subminer stats -b # Start/reuse the background stats daemon
|
subminer stats -b # Start/reuse the background stats daemon
|
||||||
subminer stats -s # Stop the background stats daemon
|
subminer stats -s # Stop the background stats daemon
|
||||||
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
||||||
|
subminer stats cleanup -d --dry-run # Preview cleanup of repeated typeset subtitle lines
|
||||||
|
subminer stats cleanup -d --lookback-days 30 # Clean only lines recorded in the last 30 days
|
||||||
subminer stats rebuild # Rebuild rollup data
|
subminer stats rebuild # Rebuild rollup data
|
||||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
subminer doctor --refresh-known-words # Refresh the known-word cache
|
||||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||||
@@ -107,6 +109,8 @@ subminer app --stop # Stop the background app
|
|||||||
subminer --version # Print the launcher's version
|
subminer --version # Print the launcher's version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`; explicitly selected modes cannot be combined. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history.
|
||||||
|
|
||||||
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -137,7 +141,7 @@ SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
|||||||
SubMiner.AppImage --help # Show all options
|
SubMiner.AppImage --help # Show all options
|
||||||
```
|
```
|
||||||
|
|
||||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), the `--stats-cleanup-*` family that `subminer stats cleanup` forwards (`--stats-cleanup-vocab`, `--stats-cleanup-lifetime`, `--stats-cleanup-duplicate-lines`, and its `--stats-cleanup-dry-run` / `--stats-cleanup-lookback-days <days>` modifiers), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -145,11 +149,13 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
|
|||||||
|
|
||||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||||
|
|
||||||
|
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||||
|
|
||||||
### Logging and App Mode
|
### Logging and App Mode
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||||
@@ -368,6 +374,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
|||||||
|
|
||||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||||
|
|
||||||
|
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
### Drag-and-Drop
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
|||||||
- **Client auth:** none
|
- **Client auth:** none
|
||||||
- **Reconnects:** client-managed
|
- **Reconnects:** client-managed
|
||||||
|
|
||||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
|
|||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
|
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
|||||||
|
|
||||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||||
|
|
||||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||||
|
|||||||
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
|||||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||||
|
|
||||||
**Parsed cue structure:**
|
**Parsed cue structure:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface SubtitleCue {
|
interface SubtitleCue {
|
||||||
startTime: number; // seconds
|
startTime: number; // seconds
|
||||||
endTime: number; // seconds
|
endTime: number; // seconds
|
||||||
text: string; // raw subtitle text
|
text: string; // plain text, decoded from the source format
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Supported formats:**
|
**Supported formats:**
|
||||||
|
|
||||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
|
||||||
|
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||||
|
|
||||||
|
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||||
|
|
||||||
#### Prefetch Service Lifecycle
|
#### Prefetch Service Lifecycle
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
|||||||
### Dependency Analysis
|
### Dependency Analysis
|
||||||
|
|
||||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||||
|
|
||||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||||
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
|||||||
|
|
||||||
// Single pass: known word + frequency filtering + JLPT computed together
|
// Single pass: known word + frequency filtering + JLPT computed together
|
||||||
const annotated = tokens.map((token) => {
|
const annotated = tokens.map((token) => {
|
||||||
const isKnown = nPlusOneEnabled
|
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||||
? token.isKnown || computeIsKnown(token, deps)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||||
const frequencyRank = frequencyEnabled
|
const frequencyRank = frequencyEnabled
|
||||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const jlptLevel = jlptEnabled
|
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||||
? computeJlptLevel(token, deps.getJlptLevel)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||||
});
|
});
|
||||||
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
|||||||
### Current Behavior
|
### Current Behavior
|
||||||
|
|
||||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||||
|
|
||||||
1. Clears DOM with `innerHTML = ''`
|
1. Clears DOM with `innerHTML = ''`
|
||||||
2. Creates a `DocumentFragment`
|
2. Creates a `DocumentFragment`
|
||||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||||
@@ -256,27 +259,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
|||||||
|
|
||||||
## Combined Impact Summary
|
## Combined Impact Summary
|
||||||
|
|
||||||
| Scenario | Before | After | Improvement |
|
| Scenario | Before | After | Improvement |
|
||||||
|----------|--------|-------|-------------|
|
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Files Summary
|
## Files Summary
|
||||||
|
|
||||||
### New Files
|
### New Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-prefetch.ts`
|
- `src/core/services/subtitle-prefetch.ts`
|
||||||
- `src/core/services/subtitle-cue-parser.ts`
|
- `src/core/services/subtitle-cue-parser.ts`
|
||||||
|
|
||||||
### Modified Files
|
### Modified Files
|
||||||
|
|
||||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||||
- `src/main.ts` (wire up prefetch service)
|
- `src/main.ts` (wire up prefetch service)
|
||||||
|
|
||||||
### Test Files
|
### Test Files
|
||||||
|
|
||||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||||
- Updated tests for annotation stage (same behavior, new implementation)
|
- Updated tests for annotation stage (same behavior, new implementation)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||||
|
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap — anywhere else it would collapse lifetime totals to the session retention window.
|
||||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-14
|
Last verified: 2026-08-04
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -47,18 +47,43 @@ subtitles do not draw.
|
|||||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||||
payload.
|
synchronously, then replaces it with the tokenized payload when ready.
|
||||||
|
|
||||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
||||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||||
prefetch work and re-centers prefetch around the live playback time.
|
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||||
|
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
||||||
|
|
||||||
|
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||||
|
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||||
|
overlay receives one plain payload followed by the annotated one.
|
||||||
|
|
||||||
|
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
||||||
|
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
||||||
|
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
||||||
|
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
||||||
|
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
||||||
|
coming and prefetching would otherwise idle for the rest of the cue.
|
||||||
|
|
||||||
|
## Live Cue Delivery
|
||||||
|
|
||||||
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
|
live work does not contend for Yomitan state.
|
||||||
|
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||||
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
|
replace the current cue.
|
||||||
|
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||||
|
or character images, but cue visibility does not wait for that work.
|
||||||
|
|
||||||
## Emitted State
|
## Emitted State
|
||||||
|
|
||||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
||||||
|
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||||
|
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||||
|
when text and cue timing match.
|
||||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Documentation Catalog
|
# Documentation Catalog
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-08-13
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: finding internal docs or checking verification status
|
Read when: finding internal docs or checking verification status
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ Read when: finding internal docs or checking verification status
|
|||||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
||||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership |
|
||||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||||
|
|
||||||
## Update Rules
|
## Update Rules
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
|||||||
# Library Summary Replaces Per-Day Trends — Design
|
|
||||||
|
|
||||||
**Status:** Draft
|
|
||||||
**Date:** 2026-04-09
|
|
||||||
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
|
|
||||||
|
|
||||||
In practice these charts are not useful:
|
|
||||||
|
|
||||||
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
|
|
||||||
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
|
|
||||||
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
|
|
||||||
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
|
|
||||||
|
|
||||||
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Changing the "Library — Cumulative" section (stays as-is).
|
|
||||||
- Changing the "Activity", "Period Trends", or "Patterns" sections.
|
|
||||||
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
|
|
||||||
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
|
|
||||||
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
|
|
||||||
|
|
||||||
## Solution Overview
|
|
||||||
|
|
||||||
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
|
|
||||||
|
|
||||||
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
|
|
||||||
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
|
|
||||||
|
|
||||||
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
|
|
||||||
|
|
||||||
## Backend
|
|
||||||
|
|
||||||
### New type
|
|
||||||
|
|
||||||
Add to `stats/src/types/stats.ts` and the backend query module:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type LibrarySummaryRow = {
|
|
||||||
title: string; // display title — anime series, YouTube video title, etc.
|
|
||||||
watchTimeMin: number; // sum(total_active_min) across the window
|
|
||||||
videos: number; // distinct video_id count
|
|
||||||
sessions: number; // session count from imm_sessions
|
|
||||||
cards: number; // sum(total_cards)
|
|
||||||
words: number; // sum(total_tokens_seen)
|
|
||||||
lookups: number; // sum(lookup_count) from imm_sessions
|
|
||||||
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
|
|
||||||
firstWatched: number; // min(rollup_day) as epoch day, within the window
|
|
||||||
lastWatched: number; // max(rollup_day) as epoch day, within the window
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
|
|
||||||
|
|
||||||
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
|
|
||||||
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos` → `imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
|
|
||||||
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
|
|
||||||
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
|
|
||||||
- Emit `lookupsPerHundred: null` when `words == 0`.
|
|
||||||
|
|
||||||
### Removed from API response
|
|
||||||
|
|
||||||
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
|
|
||||||
|
|
||||||
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
|
|
||||||
|
|
||||||
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
|
|
||||||
|
|
||||||
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
|
|
||||||
|
|
||||||
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
|
|
||||||
|
|
||||||
### Layout
|
|
||||||
|
|
||||||
Replaces `TrendsTab.tsx:224-254`:
|
|
||||||
|
|
||||||
```
|
|
||||||
[SectionHeader: "Library — Summary"]
|
|
||||||
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
|
|
||||||
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
|
|
||||||
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
|
|
||||||
```
|
|
||||||
|
|
||||||
Both cards use the existing chart/card wrapper styling.
|
|
||||||
|
|
||||||
### Leaderboard chart
|
|
||||||
|
|
||||||
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
|
|
||||||
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
|
|
||||||
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
|
|
||||||
- X-axis: minutes (number).
|
|
||||||
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
|
|
||||||
- Single series color: `#8aadf4` (matching the existing Watch Time color).
|
|
||||||
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
|
|
||||||
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
|
|
||||||
|
|
||||||
### Table
|
|
||||||
|
|
||||||
- Plain HTML `<table>` with Tailwind classes. No new deps.
|
|
||||||
- Columns, in order:
|
|
||||||
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
|
|
||||||
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
|
|
||||||
3. **Videos** — integer.
|
|
||||||
4. **Sessions** — integer.
|
|
||||||
5. **Cards** — integer.
|
|
||||||
6. **Words** — integer.
|
|
||||||
7. **Lookups** — integer.
|
|
||||||
8. **Lookups/100w** — one decimal place, `—` when null.
|
|
||||||
9. **Date Range** — `Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
|
|
||||||
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
|
|
||||||
- Numeric columns right-aligned.
|
|
||||||
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
|
|
||||||
- Row hover highlight; no row click action (read-only view).
|
|
||||||
- Empty state: "No library activity in the selected window."
|
|
||||||
|
|
||||||
### Visibility filter integration
|
|
||||||
|
|
||||||
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
|
|
||||||
|
|
||||||
### `TrendsTab.tsx` changes
|
|
||||||
|
|
||||||
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
|
|
||||||
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
|
|
||||||
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
|
|
||||||
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
|
|
||||||
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
|
|
||||||
2. Response now includes `librarySummary` (sorted by watch time desc).
|
|
||||||
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
|
|
||||||
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
|
|
||||||
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
|
|
||||||
|
|
||||||
## Edge cases
|
|
||||||
|
|
||||||
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
|
|
||||||
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
|
|
||||||
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
|
|
||||||
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
|
|
||||||
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
|
|
||||||
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Backend (`query-trends.ts`)
|
|
||||||
|
|
||||||
New unit tests, following the existing pattern:
|
|
||||||
|
|
||||||
1. Empty window returns `librarySummary: []`.
|
|
||||||
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
|
|
||||||
3. Multiple titles: rows returned sorted by watch time desc.
|
|
||||||
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
|
|
||||||
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
|
|
||||||
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
|
|
||||||
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- Existing Trends tab smoke test should continue to pass after wiring.
|
|
||||||
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
|
|
||||||
|
|
||||||
## Release / docs
|
|
||||||
|
|
||||||
- One fragment in `changes/*.md` summarizing the replacement.
|
|
||||||
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
|
|
||||||
|
|
||||||
## Open items
|
|
||||||
|
|
||||||
None.
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# Stats Dashboard Feedback Pass — Design
|
|
||||||
|
|
||||||
Date: 2026-04-09
|
|
||||||
Scope: Stats dashboard UX follow-ups from user feedback (items 1–7).
|
|
||||||
Delivery: **Single PR**, broken into logically scoped commits.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
Address seven concrete pieces of feedback against the Statistics menu:
|
|
||||||
|
|
||||||
1. Library — collapse episodes behind a per-series dropdown.
|
|
||||||
2. Sessions — roll up multiple sessions of the same episode within a day.
|
|
||||||
3. Trends — add a 365d range option.
|
|
||||||
4. Library — delete an episode (video) from its detail view.
|
|
||||||
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
|
|
||||||
6. Episode detail — hide cards whose Anki notes have been deleted.
|
|
||||||
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
|
|
||||||
|
|
||||||
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
|
|
||||||
|
|
||||||
## Files touched (inventory)
|
|
||||||
|
|
||||||
Dashboard (`stats/src/`):
|
|
||||||
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
|
|
||||||
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
|
|
||||||
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
|
|
||||||
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
|
|
||||||
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
|
|
||||||
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
|
|
||||||
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
|
|
||||||
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
|
|
||||||
|
|
||||||
Backend (`src/core/services/`):
|
|
||||||
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
|
|
||||||
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
|
|
||||||
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
|
|
||||||
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
|
|
||||||
|
|
||||||
## Commit plan
|
|
||||||
|
|
||||||
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
|
|
||||||
|
|
||||||
1. `feat(stats): add 365d range to trends dashboard` (item 3)
|
|
||||||
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
|
|
||||||
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
|
|
||||||
4. `feat(stats): delete episode from library detail view` (item 4)
|
|
||||||
5. `feat(stats): collapsible series groups in library` (item 1)
|
|
||||||
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
|
|
||||||
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
|
|
||||||
|
|
||||||
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 1 — Library collapsible series groups
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
|
|
||||||
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
|
|
||||||
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
|
|
||||||
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
|
|
||||||
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
|
|
||||||
- Multi-video group renders collapsed on first mount.
|
|
||||||
- Single-video group renders expanded on first mount.
|
|
||||||
- Clicking the header toggles visibility.
|
|
||||||
- Header summary is visible in both states.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 2 — Sessions episode rollup within a day
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- New helper in `stats/src/lib/session-grouping.ts`:
|
|
||||||
```ts
|
|
||||||
export interface SessionBucket {
|
|
||||||
key: string; // videoId as string, or `s-${sessionId}` for singletons
|
|
||||||
videoId: number | null;
|
|
||||||
sessions: SessionSummary[];
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCardsMined: number;
|
|
||||||
representativeSession: SessionSummary; // most recent, for header display
|
|
||||||
}
|
|
||||||
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
|
|
||||||
```
|
|
||||||
Sessions missing a `videoId` become singleton buckets.
|
|
||||||
|
|
||||||
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
|
|
||||||
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
|
|
||||||
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
|
|
||||||
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
|
|
||||||
|
|
||||||
### Delete semantics
|
|
||||||
|
|
||||||
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
|
|
||||||
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- `session-grouping.test.ts`:
|
|
||||||
- Empty input → empty output.
|
|
||||||
- All unique videos → N singleton buckets.
|
|
||||||
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
|
|
||||||
- Missing videoId → singleton bucket keyed by sessionId.
|
|
||||||
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 3 — 365d trends range
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
`src/core/services/immersion-tracker/query-trends.ts`:
|
|
||||||
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
|
|
||||||
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
|
|
||||||
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
|
|
||||||
|
|
||||||
`src/core/services/stats-server.ts`:
|
|
||||||
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- `hooks/useTrends.ts`: widen the `TimeRange` union.
|
|
||||||
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
|
|
||||||
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
|
|
||||||
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
|
|
||||||
- `api-client.test.ts`: ensure the client emits the new range.
|
|
||||||
|
|
||||||
### Change-specific checks
|
|
||||||
|
|
||||||
- `bun run test:config` is not required here (no schema/defaults change).
|
|
||||||
- Run `bun run typecheck` + `bun run test:fast`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 4 — Delete episode from library detail
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
|
|
||||||
- In `MediaDetailView`:
|
|
||||||
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
|
|
||||||
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
|
|
||||||
- In `LibraryTab`:
|
|
||||||
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
|
|
||||||
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
|
|
||||||
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 5 — Vocabulary word/reading column collapse
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Drop the `<th>Reading</th>` header and cell.
|
|
||||||
- Word cell becomes:
|
|
||||||
```tsx
|
|
||||||
<td className="py-1.5 pr-3">
|
|
||||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
|
||||||
{reading && (
|
|
||||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
|
||||||
【{reading}】
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
```
|
|
||||||
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
|
|
||||||
- Keep `fullReading` import from `reading-utils`.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
|
|
||||||
- Headword renders.
|
|
||||||
- Reading renders when different from headword.
|
|
||||||
- Reading does not render when equal to headword.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 6 — Hide Anki-deleted cards in Cards Mined
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
After `ankiNotesInfo` resolves:
|
|
||||||
- Drop `noteId`s that are not in the resolved map.
|
|
||||||
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
|
|
||||||
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
|
|
||||||
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
|
|
||||||
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
|
|
||||||
|
|
||||||
### Other call sites
|
|
||||||
|
|
||||||
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Item 7 — Trend/watch chart clarity pass
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
|
|
||||||
- No `CartesianGrid` → no horizontal reference lines.
|
|
||||||
- 9px axis ticks → borderline unreadable.
|
|
||||||
- Height 120 → cramped.
|
|
||||||
- Tooltip uses raw labels (`04/04` etc.).
|
|
||||||
- No shared theme object; each chart redefines colors and tooltip styles inline.
|
|
||||||
|
|
||||||
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
|
|
||||||
|
|
||||||
### Target behavior
|
|
||||||
|
|
||||||
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
|
|
||||||
```ts
|
|
||||||
export const CHART_THEME = {
|
|
||||||
tick: '#a5adcb',
|
|
||||||
tooltipBg: '#363a4f',
|
|
||||||
tooltipBorder: '#494d64',
|
|
||||||
tooltipText: '#cad3f5',
|
|
||||||
tooltipLabel: '#b8c0e0',
|
|
||||||
barFill: '#8aadf4',
|
|
||||||
grid: '#494d64',
|
|
||||||
axisLine: '#494d64',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const CHART_DEFAULTS = {
|
|
||||||
height: 160,
|
|
||||||
tickFontSize: 11,
|
|
||||||
margin: { top: 8, right: 8, bottom: 0, left: 0 },
|
|
||||||
grid: { strokeDasharray: '3 3', vertical: false },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const TOOLTIP_CONTENT_STYLE = {
|
|
||||||
background: CHART_THEME.tooltipBg,
|
|
||||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
|
||||||
borderRadius: 6,
|
|
||||||
color: CHART_THEME.tooltipText,
|
|
||||||
fontSize: 12,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply to each chart:
|
|
||||||
- Import `CartesianGrid` from recharts.
|
|
||||||
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
|
|
||||||
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
|
|
||||||
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
|
|
||||||
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
|
|
||||||
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
|
|
||||||
|
|
||||||
### Unit formatters
|
|
||||||
|
|
||||||
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
|
|
||||||
- `TrendChart` snapshot/render tests: no regression, gridline element present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification gate
|
|
||||||
|
|
||||||
Before requesting code review, run:
|
|
||||||
|
|
||||||
```
|
|
||||||
bun run typecheck
|
|
||||||
bun run test:fast
|
|
||||||
bun run test:env
|
|
||||||
bun run test:runtime:compat # dist-sensitive check for the charts
|
|
||||||
bun run build
|
|
||||||
bun run test:smoke:dist
|
|
||||||
```
|
|
||||||
|
|
||||||
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
|
|
||||||
|
|
||||||
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
|
|
||||||
|
|
||||||
## Risks and open questions
|
|
||||||
|
|
||||||
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
|
|
||||||
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
|
|
||||||
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
|
|
||||||
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
|
|
||||||
|
|
||||||
## Changelog entry
|
|
||||||
|
|
||||||
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
|
|
||||||
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-08-13
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: planning or executing nontrivial work in this repo
|
Read when: planning or executing nontrivial work in this repo
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ This section is the internal workflow map for contributors and agents.
|
|||||||
|
|
||||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||||
- [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills
|
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
||||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||||
|
|
||||||
## Default Flow
|
## Default Flow
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<!-- read_when: using or modifying repo-local agent plugins -->
|
|
||||||
|
|
||||||
# Agent Plugins
|
|
||||||
|
|
||||||
Status: active
|
|
||||||
Last verified: 2026-05-23
|
|
||||||
Owner: Kyle Yasuda
|
|
||||||
Read when: packaging or migrating repo-local agent workflow skills into plugins
|
|
||||||
|
|
||||||
## SubMiner Workflow Plugin
|
|
||||||
|
|
||||||
- Canonical plugin path: `plugins/subminer-workflow/`
|
|
||||||
- Marketplace catalog: `.agents/plugins/marketplace.json`
|
|
||||||
- Canonical skill sources:
|
|
||||||
- `plugins/subminer-workflow/skills/subminer-scrum-master/`
|
|
||||||
- `plugins/subminer-workflow/skills/subminer-change-verification/`
|
|
||||||
|
|
||||||
## Migration Rule
|
|
||||||
|
|
||||||
- Plugin-owned skills are the source of truth.
|
|
||||||
- `.agents/skills/subminer-*` remain only as compatibility shims.
|
|
||||||
- Existing script entrypoints under `.agents/skills/subminer-change-verification/scripts/` stay as wrappers so historical commands do not break.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- For plugin/docs-only changes, start with `bun run test:docs:kb`.
|
|
||||||
- Use the plugin-owned verifier when the change crosses from docs into scripts or workflow logic.
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!-- read_when: using or modifying repo-local agent skills -->
|
||||||
|
|
||||||
|
# Agent Skills
|
||||||
|
|
||||||
|
Status: active
|
||||||
|
Last verified: 2026-08-13
|
||||||
|
Owner: Kyle Yasuda
|
||||||
|
Read when: using, adding, or changing a repo-local agent workflow skill
|
||||||
|
|
||||||
|
## Canonical Skills
|
||||||
|
|
||||||
|
- `.agents/skills/subminer-change-verification/`
|
||||||
|
- Selects the cheapest sufficient repo-native verification lane.
|
||||||
|
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
||||||
|
|
||||||
|
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Keep each skill focused on one repeatable repository task.
|
||||||
|
- Prefer instructions over helper scripts unless deterministic tooling provides clear value beyond existing package commands.
|
||||||
|
- Keep trigger descriptions narrow enough to avoid invoking skills for unrelated requests.
|
||||||
|
- Update this page and the documentation catalog when skill ownership changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
For skill or internal workflow documentation changes, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test:docs:kb
|
||||||
|
```
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
# Verification
|
# Verification
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-07-06
|
Last verified: 2026-08-13
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: selecting the right verification lane for a change
|
Read when: selecting the right verification lane for a change
|
||||||
|
|
||||||
## Lane Infrastructure
|
## Lane Infrastructure
|
||||||
|
|
||||||
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||||
directory — new test files join their lane automatically; never hand-list test
|
directory, so new test files join their lane automatically; never hand-list test
|
||||||
files in `package.json`.
|
files in `package.json`.
|
||||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||||
@@ -43,8 +43,8 @@ bun run docs:build
|
|||||||
|
|
||||||
## Cheap-First Lane Selection
|
## Cheap-First Lane Selection
|
||||||
|
|
||||||
- Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build`
|
- User-facing `docs-site/` changes: `bun run docs:test`, `bun run docs:build`
|
||||||
- Internal KB / `AGENTS.md` changes: `bun run test:docs:kb`
|
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
|
||||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ export async function runStatsCommand(
|
|||||||
if (args.statsCleanupLifetime) {
|
if (args.statsCleanupLifetime) {
|
||||||
forwarded.push('--stats-cleanup-lifetime');
|
forwarded.push('--stats-cleanup-lifetime');
|
||||||
}
|
}
|
||||||
|
if (args.statsCleanupDuplicateLines) {
|
||||||
|
forwarded.push('--stats-cleanup-duplicate-lines');
|
||||||
|
}
|
||||||
|
if (args.statsCleanupDryRun) {
|
||||||
|
forwarded.push('--stats-cleanup-dry-run');
|
||||||
|
}
|
||||||
|
if (args.statsCleanupLookbackDays) {
|
||||||
|
forwarded.push('--stats-cleanup-lookback-days', String(args.statsCleanupLookbackDays));
|
||||||
|
}
|
||||||
if (shouldForwardLogLevel(args.logLevel)) {
|
if (shouldForwardLogLevel(args.logLevel)) {
|
||||||
forwarded.push('--log-level', args.logLevel);
|
forwarded.push('--log-level', args.logLevel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
|
statsCleanupLookbackDays: null,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
syncTriggered: false,
|
||||||
syncCliTokens: [],
|
syncCliTokens: [],
|
||||||
@@ -185,6 +188,9 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
|
statsCleanupLookbackDays: null,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
syncTriggered: false,
|
||||||
syncCliTokens: [],
|
syncCliTokens: [],
|
||||||
@@ -229,6 +235,9 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
|
statsCleanupLookbackDays: null,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
syncTriggered: false,
|
||||||
syncCliTokens: [],
|
syncCliTokens: [],
|
||||||
@@ -271,6 +280,9 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
|
statsCleanupLookbackDays: null,
|
||||||
statsLogLevel: null,
|
statsLogLevel: null,
|
||||||
syncTriggered: false,
|
syncTriggered: false,
|
||||||
syncCliTokens: [],
|
syncCliTokens: [],
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export function createDefaultArgs(
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
logsExport: false,
|
logsExport: false,
|
||||||
@@ -258,6 +260,11 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||||
|
if (invocations.statsCleanupDuplicateLines) parsed.statsCleanupDuplicateLines = true;
|
||||||
|
if (invocations.statsCleanupDryRun) parsed.statsCleanupDryRun = true;
|
||||||
|
if (invocations.statsCleanupLookbackDays !== null) {
|
||||||
|
parsed.statsCleanupLookbackDays = invocations.statsCleanupLookbackDays;
|
||||||
|
}
|
||||||
if (invocations.dictionaryTarget) {
|
if (invocations.dictionaryTarget) {
|
||||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface CliInvocations {
|
|||||||
statsCleanup: boolean;
|
statsCleanup: boolean;
|
||||||
statsCleanupVocab: boolean;
|
statsCleanupVocab: boolean;
|
||||||
statsCleanupLifetime: boolean;
|
statsCleanupLifetime: boolean;
|
||||||
|
statsCleanupDuplicateLines: boolean;
|
||||||
|
statsCleanupDryRun: boolean;
|
||||||
|
statsCleanupLookbackDays: number | null;
|
||||||
statsLogLevel: string | null;
|
statsLogLevel: string | null;
|
||||||
syncTriggered: boolean;
|
syncTriggered: boolean;
|
||||||
syncCliTokens: string[];
|
syncCliTokens: string[];
|
||||||
@@ -53,6 +56,16 @@ export interface CliInvocations {
|
|||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `--lookback-days` narrows the duplicate-line cleanup; fractions are floored. */
|
||||||
|
function parseStatsLookbackDays(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||||
|
const days = Number(value);
|
||||||
|
if (!Number.isFinite(days) || days < 1) {
|
||||||
|
throw new Error('Stats --lookback-days must be at least one day.');
|
||||||
|
}
|
||||||
|
return Math.floor(days);
|
||||||
|
}
|
||||||
|
|
||||||
function applyRootOptions(program: Command): void {
|
function applyRootOptions(program: Command): void {
|
||||||
program
|
program
|
||||||
.option(
|
.option(
|
||||||
@@ -169,6 +182,9 @@ export function parseCliPrograms(
|
|||||||
let statsCleanup = false;
|
let statsCleanup = false;
|
||||||
let statsCleanupVocab = false;
|
let statsCleanupVocab = false;
|
||||||
let statsCleanupLifetime = false;
|
let statsCleanupLifetime = false;
|
||||||
|
let statsCleanupDuplicateLines = false;
|
||||||
|
let statsCleanupDryRun = false;
|
||||||
|
let statsCleanupLookbackDays: number | null = null;
|
||||||
let statsLogLevel: string | null = null;
|
let statsLogLevel: string | null = null;
|
||||||
let syncTriggered = false;
|
let syncTriggered = false;
|
||||||
let syncCliTokens: string[] = [];
|
let syncCliTokens: string[] = [];
|
||||||
@@ -269,6 +285,9 @@ export function parseCliPrograms(
|
|||||||
.option('-s, --stop', 'Stop the background stats server')
|
.option('-s, --stop', 'Stop the background stats server')
|
||||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||||
|
.option('-d, --duplicate-lines', 'Collapse repeated subtitle lines from typeset animations')
|
||||||
|
.option('--dry-run', 'Report what a cleanup would remove without changing anything')
|
||||||
|
.option('--lookback-days <days>', 'Only clean lines recorded in the last N days')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
statsTriggered = true;
|
statsTriggered = true;
|
||||||
@@ -289,13 +308,35 @@ export function parseCliPrograms(
|
|||||||
if (normalizedAction && (statsBackground || statsStop)) {
|
if (normalizedAction && (statsBackground || statsStop)) {
|
||||||
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
||||||
}
|
}
|
||||||
if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
|
if (
|
||||||
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
|
normalizedAction !== 'cleanup' &&
|
||||||
|
(options.vocab === true || options.lifetime === true || options.duplicateLines === true)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.duplicateLines !== true &&
|
||||||
|
(options.dryRun === true || options.lookbackDays !== undefined)
|
||||||
|
) {
|
||||||
|
throw new Error('Stats --dry-run and --lookback-days require --duplicate-lines.');
|
||||||
}
|
}
|
||||||
if (normalizedAction === 'cleanup') {
|
if (normalizedAction === 'cleanup') {
|
||||||
statsCleanup = true;
|
statsCleanup = true;
|
||||||
statsCleanupLifetime = options.lifetime === true;
|
statsCleanupLifetime = options.lifetime === true;
|
||||||
statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
|
statsCleanupDuplicateLines = options.duplicateLines === true;
|
||||||
|
const explicitModeCount = [options.vocab, options.lifetime, options.duplicateLines].filter(
|
||||||
|
(value) => value === true,
|
||||||
|
).length;
|
||||||
|
if (explicitModeCount > 1) {
|
||||||
|
throw new Error('Stats cleanup runs one mode at a time.');
|
||||||
|
}
|
||||||
|
// Vocabulary cleanup stays the default so `stats cleanup` keeps its old meaning.
|
||||||
|
statsCleanupVocab =
|
||||||
|
statsCleanupLifetime || statsCleanupDuplicateLines ? false : options.vocab !== false;
|
||||||
|
statsCleanupDryRun = options.dryRun === true;
|
||||||
|
statsCleanupLookbackDays = parseStatsLookbackDays(options.lookbackDays);
|
||||||
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
|
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
|
||||||
statsCleanup = true;
|
statsCleanup = true;
|
||||||
statsCleanupLifetime = true;
|
statsCleanupLifetime = true;
|
||||||
@@ -483,6 +524,9 @@ export function parseCliPrograms(
|
|||||||
statsCleanup,
|
statsCleanup,
|
||||||
statsCleanupVocab,
|
statsCleanupVocab,
|
||||||
statsCleanupLifetime,
|
statsCleanupLifetime,
|
||||||
|
statsCleanupDuplicateLines,
|
||||||
|
statsCleanupDryRun,
|
||||||
|
statsCleanupLookbackDays,
|
||||||
statsLogLevel,
|
statsLogLevel,
|
||||||
syncTriggered,
|
syncTriggered,
|
||||||
syncCliTokens,
|
syncCliTokens,
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway a
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
|
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => {
|
||||||
withPlatform('linux', () => {
|
withPlatform('linux', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
||||||
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend res
|
|||||||
WAYLAND_DISPLAY: 'wayland-0',
|
WAYLAND_DISPLAY: 'wayland-0',
|
||||||
XDG_SESSION_TYPE: 'wayland',
|
XDG_SESSION_TYPE: 'wayland',
|
||||||
}),
|
}),
|
||||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
['--gpu-context=x11vk,x11egl,x11'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
|
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => {
|
||||||
withPlatform('linux', () => {
|
withPlatform('linux', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||||
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Way
|
|||||||
XDG_CURRENT_DESKTOP: 'KDE',
|
XDG_CURRENT_DESKTOP: 'KDE',
|
||||||
XDG_SESSION_DESKTOP: 'plasma',
|
XDG_SESSION_DESKTOP: 'plasma',
|
||||||
}),
|
}),
|
||||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
['--gpu-context=x11vk,x11egl,x11'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -292,9 +292,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
|||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--vo=gpu',
|
'--gpu-context=x11vk,x11egl,x11',
|
||||||
'--gpu-api=opengl',
|
|
||||||
'--gpu-context=x11egl,x11',
|
|
||||||
'--window-maximized=yes',
|
'--window-maximized=yes',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -232,6 +232,75 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
|
|||||||
assert.equal(parsed.statsCleanupLifetime, true);
|
assert.equal(parsed.statsCleanupLifetime, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps duplicate-line stats cleanup flags', () => {
|
||||||
|
const parsed = parseArgs(
|
||||||
|
['stats', 'cleanup', '--duplicate-lines', '--dry-run', '--lookback-days', '30'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(parsed.statsCleanup, true);
|
||||||
|
assert.equal(parsed.statsCleanupVocab, false);
|
||||||
|
assert.equal(parsed.statsCleanupDuplicateLines, true);
|
||||||
|
assert.equal(parsed.statsCleanupDryRun, true);
|
||||||
|
assert.equal(parsed.statsCleanupLookbackDays, 30);
|
||||||
|
|
||||||
|
const fractional = parseArgs(
|
||||||
|
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', '1.5'],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(fractional.statsCleanupLookbackDays, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs rejects duplicate-line flags without the duplicate-lines mode', () => {
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
parseArgs(['stats', 'cleanup', '--dry-run'], 'subminer', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs rejects an empty lookback value outside duplicate-line cleanup', () => {
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
parseArgs(['stats', '--lookback-days', ''], 'subminer', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs rejects combining explicit cleanup modes', () => {
|
||||||
|
for (const modes of [
|
||||||
|
['--lifetime', '--duplicate-lines'],
|
||||||
|
['--vocab', '--duplicate-lines'],
|
||||||
|
['--vocab', '--lifetime'],
|
||||||
|
]) {
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
parseArgs(['stats', 'cleanup', ...modes], 'subminer', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
assert.match(error.stderr, /Stats cleanup runs one mode at a time/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs rejects unusable lookback windows', () => {
|
||||||
|
for (const value of ['0', '0.5', '-5', 'soon']) {
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
parseArgs(
|
||||||
|
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', value],
|
||||||
|
'subminer',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
assert.match(error.stderr, /--lookback-days must be at least one day/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
|
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
|
||||||
const error = withProcessExitIntercept(() => {
|
const error = withProcessExitIntercept(() => {
|
||||||
parseArgs(['stats', '--vocab'], 'subminer', {});
|
parseArgs(['stats', '--vocab'], 'subminer', {});
|
||||||
@@ -239,7 +308,10 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
|
|||||||
|
|
||||||
assert.equal(error.code, 1);
|
assert.equal(error.code, 1);
|
||||||
assert.match(error.message, /exit:1/);
|
assert.match(error.message, /exit:1/);
|
||||||
assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
|
assert.match(
|
||||||
|
error.stderr,
|
||||||
|
/Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
word_id INTEGER NOT NULL,
|
word_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, word_id),
|
PRIMARY KEY(line_id, word_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||||
@@ -193,6 +194,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
kanji_id INTEGER NOT NULL,
|
kanji_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
|
seen_ms INTEGER,
|
||||||
PRIMARY KEY(line_id, kanji_id),
|
PRIMARY KEY(line_id, kanji_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||||
@@ -313,8 +315,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
||||||
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
||||||
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
||||||
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
|
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
|
||||||
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
|
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
|
||||||
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
||||||
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
||||||
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export interface Args {
|
|||||||
statsCleanup?: boolean;
|
statsCleanup?: boolean;
|
||||||
statsCleanupVocab?: boolean;
|
statsCleanupVocab?: boolean;
|
||||||
statsCleanupLifetime?: boolean;
|
statsCleanupLifetime?: boolean;
|
||||||
|
statsCleanupDuplicateLines?: boolean;
|
||||||
|
statsCleanupDryRun?: boolean;
|
||||||
|
statsCleanupLookbackDays?: number;
|
||||||
dictionaryTarget?: string;
|
dictionaryTarget?: string;
|
||||||
doctor: boolean;
|
doctor: boolean;
|
||||||
doctorRefreshKnownWords: boolean;
|
doctorRefreshKnownWords: boolean;
|
||||||
|
|||||||
+11
-5
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0-beta.4",
|
"version": "0.19.3",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -84,16 +84,18 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.1",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7"
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -258,6 +260,10 @@
|
|||||||
{
|
{
|
||||||
"from": "dist/launcher/subminer",
|
"from": "dist/launcher/subminer",
|
||||||
"to": "launcher/subminer"
|
"to": "launcher/subminer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "CHANGELOG.md",
|
||||||
|
"to": "CHANGELOG.md"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "subminer-workflow",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Repo-local SubMiner agent workflow plugin for orchestration and change verification.",
|
|
||||||
"author": {
|
|
||||||
"name": "Kyle Yasuda",
|
|
||||||
"email": "suda@sudacode.com",
|
|
||||||
"url": "https://github.com/sudacode"
|
|
||||||
},
|
|
||||||
"homepage": "https://github.com/sudacode/SubMiner/tree/main/plugins/subminer-workflow",
|
|
||||||
"repository": "https://github.com/sudacode/SubMiner",
|
|
||||||
"license": "GPL-3.0-or-later",
|
|
||||||
"keywords": ["subminer", "workflow", "verification", "skills"],
|
|
||||||
"skills": "./skills/",
|
|
||||||
"interface": {
|
|
||||||
"displayName": "SubMiner Workflow",
|
|
||||||
"shortDescription": "SubMiner orchestration and verification.",
|
|
||||||
"longDescription": "Canonical repo-local plugin for SubMiner agent workflow packaging. Owns the scrum-master and change-verification skills plus helper scripts used to plan, verify, and validate changes reproducibly inside this repo.",
|
|
||||||
"developerName": "Kyle Yasuda",
|
|
||||||
"category": "Productivity",
|
|
||||||
"capabilities": ["Interactive", "Write"],
|
|
||||||
"websiteURL": "https://github.com/sudacode/SubMiner",
|
|
||||||
"defaultPrompt": [
|
|
||||||
"Use SubMiner workflow to plan and ship a feature.",
|
|
||||||
"Verify a SubMiner change with the plugin-owned verifier.",
|
|
||||||
"Plan and ship this SubMiner task."
|
|
||||||
],
|
|
||||||
"brandColor": "#2F6B4F"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<!-- read_when: migrating or using the repo-local SubMiner workflow plugin -->
|
|
||||||
|
|
||||||
# SubMiner Workflow Plugin
|
|
||||||
|
|
||||||
Status: active
|
|
||||||
Last verified: 2026-03-26
|
|
||||||
Owner: Kyle Yasuda
|
|
||||||
Read when: using or updating the repo-local plugin that owns SubMiner agent workflow skills
|
|
||||||
|
|
||||||
This plugin is the canonical source of truth for the SubMiner agent workflow packaging.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- `skills/subminer-scrum-master/`
|
|
||||||
- intake, planning, dispatch, and handoff workflow
|
|
||||||
- `skills/subminer-change-verification/`
|
|
||||||
- cheap-first verification workflow plus helper scripts
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
- `.agents/skills/subminer-scrum-master/` is a compatibility shim that redirects to the plugin-owned skill.
|
|
||||||
- `.agents/skills/subminer-change-verification/` is a compatibility shim.
|
|
||||||
- `.agents/skills/subminer-change-verification/scripts/*.sh` remain as wrapper entrypoints so existing docs and shell history keep working.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
For plugin/doc/shim changes, prefer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run test:docs:kb
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane core \
|
|
||||||
plugins/subminer-workflow \
|
|
||||||
.agents/skills/subminer-scrum-master/SKILL.md \
|
|
||||||
.agents/skills/subminer-change-verification/SKILL.md \
|
|
||||||
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
|
||||||
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
|
||||||
.agents/plugins/marketplace.json \
|
|
||||||
docs/workflow/README.md \
|
|
||||||
docs/workflow/agent-plugins.md
|
|
||||||
```
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
---
|
|
||||||
name: 'subminer-change-verification'
|
|
||||||
description: 'Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation.'
|
|
||||||
---
|
|
||||||
|
|
||||||
# SubMiner Change Verification
|
|
||||||
|
|
||||||
Canonical source: this plugin path.
|
|
||||||
|
|
||||||
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
- `scripts/classify_subminer_diff.sh`
|
|
||||||
- Emits suggested lanes and flags from explicit paths or current git changes.
|
|
||||||
- `scripts/verify_subminer_change.sh`
|
|
||||||
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
|
||||||
|
|
||||||
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SUBMINER_VERIFY_SKILL="<path-to-plugin-skill>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Default workflow
|
|
||||||
|
|
||||||
1. Inspect the changed files or user-requested area.
|
|
||||||
2. Run the classifier unless you already know the right lane.
|
|
||||||
3. Run the verifier with the cheapest sufficient lane set.
|
|
||||||
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
|
||||||
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
|
||||||
6. Return:
|
|
||||||
- verification summary
|
|
||||||
- exact commands run
|
|
||||||
- artifact paths
|
|
||||||
- skipped lanes and blockers
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Plugin-source quick start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Installed-skill quick start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatibility entrypoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Classify explicit files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
|
||||||
launcher/main.ts \
|
|
||||||
plugin/subminer/lifecycle.lua \
|
|
||||||
src/main/runtime/mpv-client-runtime-service.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Run automatic lane selection:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Installed-skill form:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatibility entrypoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Run targeted lanes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
|
||||||
--lane launcher-plugin \
|
|
||||||
--lane runtime-compat
|
|
||||||
```
|
|
||||||
|
|
||||||
Dry-run to inspect planned commands and artifact layout:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
|
||||||
--dry-run \
|
|
||||||
launcher/main.ts \
|
|
||||||
src/main.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lane guidance
|
|
||||||
|
|
||||||
- `docs`
|
|
||||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
|
||||||
- `config`
|
|
||||||
- For `src/config/` and config-template-sensitive edits.
|
|
||||||
- `stats`
|
|
||||||
- For `stats/` dashboard UI edits.
|
|
||||||
- `core`
|
|
||||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
|
||||||
- `launcher-plugin`
|
|
||||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
|
||||||
- `runtime-compat`
|
|
||||||
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
|
||||||
- `real-runtime`
|
|
||||||
- Only after deliberate escalation.
|
|
||||||
|
|
||||||
## Real Runtime Escalation
|
|
||||||
|
|
||||||
Escalate only when the change claim depends on actual runtime behavior, for example:
|
|
||||||
|
|
||||||
- overlay appears, hides, or tracks a real mpv window
|
|
||||||
- mpv launch flags or pause-until-ready behavior
|
|
||||||
- plugin/socket/auto-start handshake under a real player
|
|
||||||
- macOS/window-tracker/focus-sensitive behavior
|
|
||||||
|
|
||||||
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
|
||||||
|
|
||||||
## Artifact contract
|
|
||||||
|
|
||||||
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
|
||||||
|
|
||||||
- `summary.json`
|
|
||||||
- `summary.txt`
|
|
||||||
- `classification.txt`
|
|
||||||
- `env.txt`
|
|
||||||
- `lanes.txt`
|
|
||||||
- `steps.tsv`
|
|
||||||
- `steps/*.stdout.log`
|
|
||||||
- `steps/*.stderr.log`
|
|
||||||
|
|
||||||
On failure, quote the exact failing command and point at the artifact directory.
|
|
||||||
-171
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: classify_subminer_diff.sh [path ...]
|
|
||||||
|
|
||||||
Emit suggested verification lanes for explicit paths or current local git changes.
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
lane:<name>
|
|
||||||
flag:<name>
|
|
||||||
reason:<text>
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
has_item() {
|
|
||||||
local needle=$1
|
|
||||||
shift || true
|
|
||||||
local item
|
|
||||||
for item in "$@"; do
|
|
||||||
if [[ "$item" == "$needle" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
add_lane() {
|
|
||||||
local lane=$1
|
|
||||||
if ! has_item "$lane" "${LANES[@]:-}"; then
|
|
||||||
LANES+=("$lane")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_flag() {
|
|
||||||
local flag=$1
|
|
||||||
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
|
||||||
FLAGS+=("$flag")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_reason() {
|
|
||||||
REASONS+=("$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
collect_git_paths() {
|
|
||||||
local top_level
|
|
||||||
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$top_level"
|
|
||||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
||||||
git diff --name-only --relative HEAD --
|
|
||||||
git diff --name-only --relative --cached --
|
|
||||||
else
|
|
||||||
git diff --name-only --relative --
|
|
||||||
git diff --name-only --relative --cached --
|
|
||||||
fi
|
|
||||||
git ls-files --others --exclude-standard
|
|
||||||
) | awk 'NF' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -a PATHS=()
|
|
||||||
declare -a LANES=()
|
|
||||||
declare -a FLAGS=()
|
|
||||||
declare -a REASONS=()
|
|
||||||
|
|
||||||
if [[ $# -gt 0 ]]; then
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
PATHS+=("$1")
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
else
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[[ -n "$line" ]] && PATHS+=("$line")
|
|
||||||
done < <(collect_git_paths)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "no changed paths detected -> default to core"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for path in "${PATHS[@]}"; do
|
|
||||||
specialized=0
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
docs-site/*|docs/*|changes/*|README.md)
|
|
||||||
add_lane "docs"
|
|
||||||
add_reason "$path -> docs"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
|
||||||
add_lane "config"
|
|
||||||
add_reason "$path -> config"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
stats/*)
|
|
||||||
add_lane "stats"
|
|
||||||
add_reason "$path -> stats"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
|
||||||
add_lane "launcher-plugin"
|
|
||||||
add_reason "$path -> launcher-plugin"
|
|
||||||
add_flag "real-runtime-candidate"
|
|
||||||
add_reason "$path -> real-runtime-candidate"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
|
||||||
add_lane "runtime-compat"
|
|
||||||
add_reason "$path -> runtime-compat"
|
|
||||||
add_flag "real-runtime-candidate"
|
|
||||||
add_reason "$path -> real-runtime-candidate"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "$specialized" == "0" ]]; then
|
|
||||||
case "$path" in
|
|
||||||
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "$path -> core"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
|
||||||
add_flag "broad-impact"
|
|
||||||
add_reason "$path -> broad-impact"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#LANES[@]} -eq 0 ]]; then
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "no lane-specific matches -> default to core"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for lane in "${LANES[@]}"; do
|
|
||||||
printf 'lane:%s\n' "$lane"
|
|
||||||
done
|
|
||||||
|
|
||||||
for flag in "${FLAGS[@]}"; do
|
|
||||||
printf 'flag:%s\n' "$flag"
|
|
||||||
done
|
|
||||||
|
|
||||||
for reason in "${REASONS[@]}"; do
|
|
||||||
printf 'reason:%s\n' "$reason"
|
|
||||||
done
|
|
||||||
-537
@@ -1,537 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: verify_subminer_change.sh [options] [path ...]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--lane <name> Force a verification lane. Repeatable.
|
|
||||||
--artifact-dir <dir> Use an explicit artifact directory.
|
|
||||||
--allow-real-runtime Allow explicit real-runtime execution.
|
|
||||||
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
|
||||||
--dry-run Record planned steps without executing commands.
|
|
||||||
--help Show this help text.
|
|
||||||
|
|
||||||
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
|
||||||
provided, it classifies the current local git changes.
|
|
||||||
|
|
||||||
Authoritative real-runtime verification should be requested with explicit path
|
|
||||||
arguments instead of relying on inferred local git changes.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp() {
|
|
||||||
date +%Y%m%d-%H%M%S
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp_iso() {
|
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_session_id() {
|
|
||||||
local tmp_dir
|
|
||||||
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
|
||||||
basename "$tmp_dir"
|
|
||||||
rmdir "$tmp_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
has_item() {
|
|
||||||
local needle=$1
|
|
||||||
shift || true
|
|
||||||
local item
|
|
||||||
for item in "$@"; do
|
|
||||||
if [[ "$item" == "$needle" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize_lane_name() {
|
|
||||||
case "$1" in
|
|
||||||
real-gui)
|
|
||||||
printf '%s' "real-runtime"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
printf '%s' "$1"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
add_lane() {
|
|
||||||
local lane
|
|
||||||
lane=$(normalize_lane_name "$1")
|
|
||||||
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
|
||||||
SELECTED_LANES+=("$lane")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_blocker() {
|
|
||||||
BLOCKERS+=("$1")
|
|
||||||
BLOCKED=1
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_artifact_dir() {
|
|
||||||
local candidate=$1
|
|
||||||
if [[ ! "$candidate" =~ ^[A-Za-z0-9._/@:+-]+$ ]]; then
|
|
||||||
echo "Invalid characters in --artifact-dir path" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
append_step_record() {
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
||||||
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_env() {
|
|
||||||
{
|
|
||||||
printf 'repo_root=%s\n' "$REPO_ROOT"
|
|
||||||
printf 'session_id=%s\n' "$SESSION_ID"
|
|
||||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
|
||||||
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
|
||||||
printf 'dry_run=%s\n' "$DRY_RUN"
|
|
||||||
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
|
||||||
printf 'session_home=%s\n' "$SESSION_HOME"
|
|
||||||
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
|
||||||
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
|
||||||
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
|
||||||
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
|
||||||
printf 'pwd=%s\n' "$(pwd)"
|
|
||||||
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
|
||||||
git status --short 2>/dev/null || true
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
printf 'requested_paths=\n'
|
|
||||||
printf ' %s\n' "${PATH_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
} >"$ARTIFACT_DIR/env.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step() {
|
|
||||||
local lane=$1
|
|
||||||
local name=$2
|
|
||||||
local command=$3
|
|
||||||
local note=${4:-}
|
|
||||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
|
||||||
local step_slug="${lane_slug}--${slug}"
|
|
||||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
|
||||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
|
||||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
|
||||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
|
||||||
local status exit_code
|
|
||||||
|
|
||||||
COMMANDS_RUN+=("$command")
|
|
||||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
|
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == "1" ]]; then
|
|
||||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
|
||||||
: >"$stderr_path"
|
|
||||||
status="dry-run"
|
|
||||||
exit_code=0
|
|
||||||
else
|
|
||||||
if HOME="$SESSION_HOME" \
|
|
||||||
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
|
|
||||||
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
|
|
||||||
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
|
|
||||||
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
|
||||||
status="passed"
|
|
||||||
exit_code=0
|
|
||||||
EXECUTED_REAL_STEPS=1
|
|
||||||
else
|
|
||||||
exit_code=$?
|
|
||||||
status="failed"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
|
||||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
|
||||||
|
|
||||||
if [[ "$status" == "failed" ]]; then
|
|
||||||
FAILURE_STEP="$name"
|
|
||||||
FAILURE_COMMAND="$command"
|
|
||||||
FAILURE_STDOUT="$stdout_rel"
|
|
||||||
FAILURE_STDERR="$stderr_rel"
|
|
||||||
return "$exit_code"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
record_nonpassing_step() {
|
|
||||||
local lane=$1
|
|
||||||
local name=$2
|
|
||||||
local status=$3
|
|
||||||
local note=$4
|
|
||||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
|
||||||
local step_slug="${lane_slug}--${slug}"
|
|
||||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
|
||||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
|
||||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
|
||||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
|
||||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
|
||||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_skipped_step() {
|
|
||||||
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_blocked_step() {
|
|
||||||
add_blocker "$3"
|
|
||||||
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_failed_step() {
|
|
||||||
FAILED=1
|
|
||||||
FAILURE_STEP=$2
|
|
||||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
|
||||||
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
|
|
||||||
local step_slug=${2//[^a-zA-Z0-9_-]/-}
|
|
||||||
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
|
|
||||||
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
|
|
||||||
add_blocker "$3"
|
|
||||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
find_real_runtime_helper() {
|
|
||||||
local candidate
|
|
||||||
for candidate in \
|
|
||||||
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
|
||||||
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
|
||||||
if [[ -x "$candidate" ]]; then
|
|
||||||
printf '%s' "$candidate"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
acquire_real_runtime_lease() {
|
|
||||||
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
|
||||||
local lease_dir="$lease_root/exclusive-real-runtime"
|
|
||||||
mkdir -p "$lease_root"
|
|
||||||
if mkdir "$lease_dir" 2>/dev/null; then
|
|
||||||
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
|
||||||
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local owner=""
|
|
||||||
if [[ -f "$lease_dir/session_id" ]]; then
|
|
||||||
owner=$(cat "$lease_dir/session_id")
|
|
||||||
fi
|
|
||||||
REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
release_real_runtime_lease() {
|
|
||||||
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
|
||||||
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
|
||||||
local owner
|
|
||||||
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
|
||||||
if [[ "$owner" != "$SESSION_ID" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
compute_final_status() {
|
|
||||||
if [[ "$FAILED" == "1" ]]; then
|
|
||||||
FINAL_STATUS="failed"
|
|
||||||
elif [[ "$BLOCKED" == "1" ]]; then
|
|
||||||
FINAL_STATUS="blocked"
|
|
||||||
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
|
||||||
FINAL_STATUS="passed"
|
|
||||||
else
|
|
||||||
FINAL_STATUS="skipped"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
write_summary_files() {
|
|
||||||
local lane_lines
|
|
||||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
|
||||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
|
||||||
# bash 3.2 raises "unbound variable" under set -u when expanding an empty
|
|
||||||
# array, so guard on length (matching the idiom used elsewhere here).
|
|
||||||
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
|
|
||||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
|
||||||
else
|
|
||||||
: >"$ARTIFACT_DIR/blockers.txt"
|
|
||||||
fi
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
|
||||||
else
|
|
||||||
: >"$ARTIFACT_DIR/requested-paths.txt"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
|
||||||
SESSION_ID_ENV="$SESSION_ID" \
|
|
||||||
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
|
||||||
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
|
||||||
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
|
||||||
SESSION_HOME_ENV="$SESSION_HOME" \
|
|
||||||
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
|
||||||
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
|
||||||
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
|
||||||
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
|
||||||
STARTED_AT_ENV="$STARTED_AT" \
|
|
||||||
FINISHED_AT_ENV="$FINISHED_AT" \
|
|
||||||
FAILED_ENV="$FAILED" \
|
|
||||||
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
|
||||||
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
|
||||||
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
|
||||||
bun -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const lines = fs
|
|
||||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "steps.tsv"), "utf8")
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(1)
|
|
||||||
.map((line) => {
|
|
||||||
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
|
||||||
return { lane, name, status, exitCode: Number(exitCode), command, stdout, stderr, note };
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
sessionId: process.env.SESSION_ID_ENV,
|
|
||||||
startedAt: process.env.STARTED_AT_ENV,
|
|
||||||
finishedAt: process.env.FINISHED_AT_ENV,
|
|
||||||
status: process.env.FINAL_STATUS_ENV,
|
|
||||||
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV,
|
|
||||||
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
|
||||||
sessionHome: process.env.SESSION_HOME_ENV,
|
|
||||||
sessionXdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV,
|
|
||||||
sessionMpvDir: process.env.SESSION_MPV_DIR_ENV,
|
|
||||||
sessionLogsDir: process.env.SESSION_LOGS_DIR_ENV,
|
|
||||||
sessionMpvLog: process.env.SESSION_MPV_LOG_ENV,
|
|
||||||
failed: process.env.FAILED_ENV === "1",
|
|
||||||
failure: process.env.FAILURE_COMMAND_ENV
|
|
||||||
? {
|
|
||||||
command: process.env.FAILURE_COMMAND_ENV,
|
|
||||||
stdout: process.env.FAILURE_STDOUT_ENV,
|
|
||||||
stderr: process.env.FAILURE_STDERR_ENV,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
blockers: fs
|
|
||||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "blockers.txt"), "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean),
|
|
||||||
lanes: fs
|
|
||||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "lanes.txt"), "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean),
|
|
||||||
requestedPaths: fs
|
|
||||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "requested-paths.txt"), "utf8")
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean),
|
|
||||||
steps: lines,
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(process.env.ARTIFACT_DIR_ENV, "summary.json"),
|
|
||||||
JSON.stringify(payload, null, 2) + "\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const summaryLines = [
|
|
||||||
`status: ${payload.status}`,
|
|
||||||
`session: ${payload.sessionId}`,
|
|
||||||
`artifacts: ${process.env.ARTIFACT_DIR_ENV}`,
|
|
||||||
`lanes: ${payload.lanes.join(", ") || "(none)"}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (payload.requestedPaths.length > 0) {
|
|
||||||
summaryLines.push("requested paths:");
|
|
||||||
for (const entry of payload.requestedPaths) {
|
|
||||||
summaryLines.push(`- ${entry}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.failure) {
|
|
||||||
summaryLines.push(`failure command: ${payload.failure.command}`);
|
|
||||||
summaryLines.push(`failure stdout: ${payload.failure.stdout}`);
|
|
||||||
summaryLines.push(`failure stderr: ${payload.failure.stderr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.blockers.length > 0) {
|
|
||||||
summaryLines.push("blockers:");
|
|
||||||
for (const blocker of payload.blockers) {
|
|
||||||
summaryLines.push(`- ${blocker}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
summaryLines.push("steps:");
|
|
||||||
for (const step of payload.steps) {
|
|
||||||
summaryLines.push(`- ${step.lane}/${step.name}: ${step.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(process.env.ARTIFACT_DIR_ENV, "summary.txt"),
|
|
||||||
summaryLines.join("\n") + "\n",
|
|
||||||
);
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
||||||
|
|
||||||
declare -a PATH_ARGS=()
|
|
||||||
declare -a SELECTED_LANES=()
|
|
||||||
declare -a COMMANDS_RUN=()
|
|
||||||
declare -a BLOCKERS=()
|
|
||||||
|
|
||||||
ALLOW_REAL_RUNTIME=0
|
|
||||||
DRY_RUN=0
|
|
||||||
FAILED=0
|
|
||||||
BLOCKED=0
|
|
||||||
EXECUTED_REAL_STEPS=0
|
|
||||||
FAILURE_STEP=""
|
|
||||||
FAILURE_COMMAND=""
|
|
||||||
FAILURE_STDOUT=""
|
|
||||||
FAILURE_STDERR=""
|
|
||||||
REAL_RUNTIME_LEASE_DIR=""
|
|
||||||
REAL_RUNTIME_LEASE_ERROR=""
|
|
||||||
PATH_SELECTION_MODE="auto"
|
|
||||||
|
|
||||||
trap 'release_real_runtime_lease' EXIT
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--lane)
|
|
||||||
shift
|
|
||||||
[[ $# -gt 0 ]] || {
|
|
||||||
echo "Missing value for --lane" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
add_lane "$1"
|
|
||||||
PATH_SELECTION_MODE="explicit-lanes"
|
|
||||||
;;
|
|
||||||
--artifact-dir)
|
|
||||||
shift
|
|
||||||
[[ $# -gt 0 ]] || {
|
|
||||||
echo "Missing value for --artifact-dir" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
ARTIFACT_DIR=$1
|
|
||||||
;;
|
|
||||||
--allow-real-runtime|--allow-real-gui)
|
|
||||||
ALLOW_REAL_RUNTIME=1
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
DRY_RUN=1
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
PATH_ARGS+=("$1")
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift || true
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "${ARTIFACT_DIR:-}" ]]; then
|
|
||||||
SESSION_ID=$(generate_session_id)
|
|
||||||
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
|
||||||
else
|
|
||||||
validate_artifact_dir "$ARTIFACT_DIR"
|
|
||||||
SESSION_ID=$(basename "$ARTIFACT_DIR")
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$ARTIFACT_DIR/steps"
|
|
||||||
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
|
||||||
printf 'lane\tstep\tstatus\texit_code\tcommand\tstdout\tstderr\tnote\n' >"$STEPS_TSV"
|
|
||||||
|
|
||||||
STARTED_AT=$(timestamp_iso)
|
|
||||||
SESSION_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/home"
|
|
||||||
SESSION_XDG_CONFIG_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/xdg-config"
|
|
||||||
SESSION_MPV_DIR="$SESSION_XDG_CONFIG_HOME/mpv"
|
|
||||||
SESSION_LOGS_DIR="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/logs"
|
|
||||||
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
|
||||||
mkdir -p "$SESSION_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
|
||||||
|
|
||||||
CLASSIFIER_OUTPUT="$ARTIFACT_DIR/classification.txt"
|
|
||||||
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
PATH_SELECTION_MODE="explicit-paths"
|
|
||||||
fi
|
|
||||||
if "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}" >"$CLASSIFIER_OUTPUT"; then
|
|
||||||
while IFS= read -r line; do
|
|
||||||
case "$line" in
|
|
||||||
lane:*)
|
|
||||||
add_lane "${line#lane:}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <"$CLASSIFIER_OUTPUT"
|
|
||||||
else
|
|
||||||
record_failed_step "meta" "classify" "classification failed"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
: >"$CLASSIFIER_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
record_env
|
|
||||||
|
|
||||||
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
|
|
||||||
add_lane "core"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for lane in "${SELECTED_LANES[@]}"; do
|
|
||||||
case "$lane" in
|
|
||||||
docs)
|
|
||||||
run_step "$lane" "docs-kb" "bun run test:docs:kb" || break
|
|
||||||
;;
|
|
||||||
config)
|
|
||||||
run_step "$lane" "config" "bun run test:config" || break
|
|
||||||
;;
|
|
||||||
stats)
|
|
||||||
run_step "$lane" "stats" "bun run test:stats" || break
|
|
||||||
;;
|
|
||||||
core)
|
|
||||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
|
||||||
run_step "$lane" "fast-tests" "bun run test:fast" || break
|
|
||||||
;;
|
|
||||||
launcher-plugin)
|
|
||||||
run_step "$lane" "launcher" "bun run test:launcher" || break
|
|
||||||
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
|
||||||
;;
|
|
||||||
runtime-compat)
|
|
||||||
run_step "$lane" "runtime-compat" "bun run test:runtime:compat" || break
|
|
||||||
;;
|
|
||||||
real-runtime)
|
|
||||||
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
|
||||||
record_blocked_step "$lane" "real-runtime" "real-runtime requested without --allow-real-runtime"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if ! acquire_real_runtime_lease; then
|
|
||||||
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
helper=$(find_real_runtime_helper || true)
|
|
||||||
if [[ -z "${helper:-}" ]]; then
|
|
||||||
record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
run_step "$lane" "real-runtime" "\"$helper\" \"$SESSION_ID\" \"$ARTIFACT_DIR\"" || break
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
record_blocked_step "$lane" "unknown-lane" "unknown lane: $lane"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
release_real_runtime_lease
|
|
||||||
FINISHED_AT=$(timestamp_iso)
|
|
||||||
compute_final_status
|
|
||||||
write_summary_files
|
|
||||||
|
|
||||||
printf 'summary:%s\n' "$ARTIFACT_DIR/summary.txt"
|
|
||||||
cat "$ARTIFACT_DIR/summary.txt"
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
name: 'subminer-scrum-master'
|
|
||||||
description: 'Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Records a plan, dispatches one or more subagents when useful, and requires verification before handoff.'
|
|
||||||
---
|
|
||||||
|
|
||||||
# SubMiner Scrum Master
|
|
||||||
|
|
||||||
Canonical source: this plugin path.
|
|
||||||
|
|
||||||
Own workflow, not code by default.
|
|
||||||
|
|
||||||
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, worker dispatch, and verification through completion.
|
|
||||||
|
|
||||||
## Core Rules
|
|
||||||
|
|
||||||
1. Keep the process light for questions, obvious mechanical edits, and tiny isolated changes.
|
|
||||||
2. Record a plan before dispatching coding work.
|
|
||||||
3. Split multi-part work into clear phases and ownership areas.
|
|
||||||
4. Dispatch conservatively. Parallelize only disjoint write scopes.
|
|
||||||
5. Require verification before handoff, typically via `subminer-change-verification`.
|
|
||||||
6. Report dispatched workers, verification, blockers, and remaining risks.
|
|
||||||
|
|
||||||
## Intake Workflow
|
|
||||||
|
|
||||||
1. Parse the request.
|
|
||||||
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
|
||||||
2. Write a short working plan in-thread when the work is nontrivial.
|
|
||||||
3. Choose execution mode:
|
|
||||||
- no subagents for trivial work
|
|
||||||
- one worker for focused work
|
|
||||||
- parallel workers only for disjoint scopes
|
|
||||||
4. Run verification before handoff.
|
|
||||||
|
|
||||||
## Dispatch Rules
|
|
||||||
|
|
||||||
The scrum master orchestrates. Workers implement.
|
|
||||||
|
|
||||||
- Do not become the default implementer unless delegation is unnecessary.
|
|
||||||
- Do not parallelize overlapping files or tightly coupled runtime work.
|
|
||||||
- Give every worker explicit ownership of files/modules.
|
|
||||||
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
|
||||||
- Require each worker to report:
|
|
||||||
- changed files
|
|
||||||
- tests run
|
|
||||||
- blockers
|
|
||||||
|
|
||||||
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Every nontrivial code task gets verification.
|
|
||||||
|
|
||||||
Preferred flow:
|
|
||||||
|
|
||||||
1. use `subminer-change-verification`
|
|
||||||
2. start with the cheapest sufficient lane
|
|
||||||
3. escalate only when needed
|
|
||||||
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
|
||||||
|
|
||||||
Never hand off nontrivial work without stating what was verified and what was skipped.
|
|
||||||
|
|
||||||
## Pre-Handoff Policy Checks
|
|
||||||
|
|
||||||
Before handoff, always ask and answer both questions explicitly:
|
|
||||||
|
|
||||||
1. Docs update required?
|
|
||||||
2. Changelog fragment required?
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Do not assume silence implies "no."
|
|
||||||
- If the answer is yes, complete the update or report the blocker.
|
|
||||||
- Include final yes/no answers in the handoff summary even when both answers are "no."
|
|
||||||
|
|
||||||
## Failure / Scope Handling
|
|
||||||
|
|
||||||
- If a worker hits ambiguity, pause and ask the user.
|
|
||||||
- If verification fails, either:
|
|
||||||
- send the worker back with exact failure context, or
|
|
||||||
- fix it directly if it is tiny and clearly in scope
|
|
||||||
- If new scope appears, pause and re-plan before silently expanding work.
|
|
||||||
|
|
||||||
## Representative Flows
|
|
||||||
|
|
||||||
### Trivial work
|
|
||||||
|
|
||||||
- keep a short plan
|
|
||||||
- implement directly or with one worker if helpful
|
|
||||||
- run targeted verification
|
|
||||||
- report outcome concisely
|
|
||||||
|
|
||||||
### Focused implementation
|
|
||||||
|
|
||||||
- record plan
|
|
||||||
- dispatch one worker
|
|
||||||
- integrate
|
|
||||||
- verify
|
|
||||||
- report outcome
|
|
||||||
|
|
||||||
### Multi-part execution
|
|
||||||
|
|
||||||
- define distinct deliverables/phases
|
|
||||||
- record sequencing in the plan
|
|
||||||
- dispatch workers only where scopes are disjoint
|
|
||||||
- integrate
|
|
||||||
- run consolidated verification
|
|
||||||
- report outcome
|
|
||||||
|
|
||||||
## Output Expectations
|
|
||||||
|
|
||||||
At the end, report:
|
|
||||||
|
|
||||||
- which workers were dispatched and what they owned
|
|
||||||
- what verification ran
|
|
||||||
- explicit answers to:
|
|
||||||
- docs update required?
|
|
||||||
- changelog fragment required?
|
|
||||||
- blockers, skips, and risks
|
|
||||||
@@ -24,6 +24,11 @@
|
|||||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
||||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
||||||
|
|
||||||
|
- **Stats Library Entry Deletion**
|
||||||
|
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
|
||||||
|
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
|
||||||
|
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Clipboard-Video Shortcut**
|
- **Clipboard-Video Shortcut**
|
||||||
@@ -34,8 +39,9 @@
|
|||||||
- **Word Highlighting Accuracy**
|
- **Word Highlighting Accuracy**
|
||||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||||
|
|
||||||
- **Character Dictionary Season Overrides**
|
- **AniList Season Resolution**
|
||||||
- Manual AniList overrides for a series now stay in effect for every episode in the same season folder, even when individual episode filenames produce different automatic guesses.
|
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
|
||||||
|
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
|
||||||
|
|
||||||
- **Startup Playback Pausing Too Early**
|
- **Startup Playback Pausing Too Early**
|
||||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||||
@@ -53,6 +59,9 @@
|
|||||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||||
|
|
||||||
|
- **Stats Library Cover After Relink**
|
||||||
|
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
|
||||||
|
|
||||||
- **Rofi Menu Prompt Spacing**
|
- **Rofi Menu Prompt Spacing**
|
||||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
||||||
|
|
||||||
@@ -64,6 +73,8 @@
|
|||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
||||||
|
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
||||||
|
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const requiredDocs = [
|
|||||||
'docs/knowledge-base/catalog.md',
|
'docs/knowledge-base/catalog.md',
|
||||||
'docs/knowledge-base/quality.md',
|
'docs/knowledge-base/quality.md',
|
||||||
'docs/workflow/README.md',
|
'docs/workflow/README.md',
|
||||||
|
'docs/workflow/agent-skills.md',
|
||||||
'docs/workflow/planning.md',
|
'docs/workflow/planning.md',
|
||||||
'docs/workflow/verification.md',
|
'docs/workflow/verification.md',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
|
||||||
const classifyScript = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
|
||||||
);
|
|
||||||
const verifyScript = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
|
||||||
);
|
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
|
||||||
try {
|
|
||||||
return fn(dir);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runBash(args: string[]) {
|
|
||||||
return spawnSync('bash', args, {
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: process.env,
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArtifactDir(stdout: string): string {
|
|
||||||
const match = stdout.match(/^artifacts: (.+)$/m);
|
|
||||||
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
|
||||||
return match[1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSummaryJson(artifactDir: string) {
|
|
||||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
|
||||||
sessionId: string;
|
|
||||||
status: string;
|
|
||||||
lanes: string[];
|
|
||||||
blockers?: string[];
|
|
||||||
artifactDir: string;
|
|
||||||
pathSelectionMode?: string;
|
|
||||||
steps: Array<{
|
|
||||||
lane: string;
|
|
||||||
name: string;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
note: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
|
||||||
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
||||||
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
|
||||||
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
|
||||||
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--lane',
|
|
||||||
'real-runtime',
|
|
||||||
'launcher/mpv.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stdout);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
assert.equal(summary.status, 'blocked');
|
|
||||||
assert.deepEqual(summary.lanes, ['real-runtime']);
|
|
||||||
assert.ok(summary.sessionId.length > 0);
|
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
|
||||||
assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier fails closed for unknown lanes', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--lane',
|
|
||||||
'not-a-lane',
|
|
||||||
'src/main.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stdout);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
assert.equal(summary.status, 'blocked');
|
|
||||||
assert.deepEqual(summary.lanes, ['not-a-lane']);
|
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--lane',
|
|
||||||
'docs',
|
|
||||||
'--lane',
|
|
||||||
'not-a-lane',
|
|
||||||
'src/main.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stdout);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
|
|
||||||
const unknownStep = summary.steps.find(
|
|
||||||
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(docsStep);
|
|
||||||
assert.ok(unknownStep);
|
|
||||||
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
|
|
||||||
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
|
|
||||||
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier records the real-runtime lease blocker once', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const leaseDir = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'.tmp',
|
|
||||||
'skill-verification',
|
|
||||||
'locks',
|
|
||||||
'exclusive-real-runtime',
|
|
||||||
);
|
|
||||||
fs.mkdirSync(leaseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--allow-real-runtime',
|
|
||||||
'--lane',
|
|
||||||
'real-runtime',
|
|
||||||
'launcher/mpv.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stdout);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(leaseDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier allocates unique session ids and artifact roots by default', () => {
|
|
||||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
|
||||||
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
|
||||||
|
|
||||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
|
||||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
|
||||||
|
|
||||||
const firstArtifactDir = parseArtifactDir(first.stdout);
|
|
||||||
const secondArtifactDir = parseArtifactDir(second.stdout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstSummary = readSummaryJson(firstArtifactDir);
|
|
||||||
const secondSummary = readSummaryJson(secondArtifactDir);
|
|
||||||
|
|
||||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
|
||||||
assert.notEqual(firstArtifactDir, secondArtifactDir);
|
|
||||||
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
|
|
||||||
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
|
||||||
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
type WordCardKind,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
|
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
|
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||||
|
this.resolveFieldName(availableFieldNames, preferredName),
|
||||||
if (cardKind === 'word-and-sentence') {
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (!wordAndSentenceFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== sentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedAudioFlags = Array.from(
|
|
||||||
new Set(
|
|
||||||
audioFlagNames
|
|
||||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
|
||||||
.filter((name): name is string => Boolean(name)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
|
||||||
if (audioFlagName) {
|
|
||||||
updatedFields[audioFlagName] = 'x';
|
|
||||||
}
|
|
||||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
|
||||||
updatedFields[extraAudioFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
if (cardKind !== 'word-and-sentence') return;
|
applyCardKindFlagFields(
|
||||||
|
updatedFields,
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
cardKind,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
|
function resolverFor(availableFieldNames: string[]) {
|
||||||
|
return (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||||
|
|
||||||
|
test('flags the requested card kind and clears the others', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches flag fields case-insensitively', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'click',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'audio',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not blank the target flag it just set', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||||
|
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||||
|
*/
|
||||||
|
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||||
|
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||||
|
click: 'IsClickCard',
|
||||||
|
sentence: 'IsSentenceCard',
|
||||||
|
audio: 'IsAudioCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||||
|
'word-and-sentence',
|
||||||
|
'click',
|
||||||
|
'sentence',
|
||||||
|
'audio',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||||
|
* when the note type actually carries the matching flag field, so plain note types keep
|
||||||
|
* their fields untouched.
|
||||||
|
*/
|
||||||
|
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||||
|
|
||||||
|
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||||
|
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||||
|
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||||
|
* never ends up claiming to be two kinds of card at once.
|
||||||
|
*/
|
||||||
|
export function applyCardKindFlagFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
cardKind: CardKind,
|
||||||
|
resolveFieldName: (preferredName: string) => string | null,
|
||||||
|
): void {
|
||||||
|
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||||
|
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetFlag) {
|
||||||
|
updatedFields[targetFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||||
|
if (kind === cardKind) continue;
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== targetFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||||
|
|
||||||
|
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
Expression: '単語',
|
||||||
|
Sentence: '',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsClickCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the configured word card kind', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the word card kind is "none"', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'none',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the default kind for an unrecognized setting', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'bogus' as never,
|
||||||
|
}),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||||
|
const note: NoteFieldValueInfo = {
|
||||||
|
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||||
|
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
flagField,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'sentence',
|
||||||
|
}),
|
||||||
|
'sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides a differently flagged word card', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
CARD_KIND_FLAG_FIELDS,
|
||||||
|
DEFAULT_WORD_CARD_KIND,
|
||||||
|
resolveWordCardKindSetting,
|
||||||
|
} from './card-kinds';
|
||||||
|
|
||||||
|
const log = createLogger('anki').child('integration.note-fields');
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldMarkWordAndSentenceCard(
|
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||||
noteInfo: NoteFieldValueInfo,
|
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
const warnedMissingFlagFields = new Set<CardKind>();
|
||||||
if (wordAndSentenceValue === null) {
|
|
||||||
return false;
|
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||||
|
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||||
|
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wordAndSentenceValue.trim().length > 0) {
|
warnedMissingFlagFields.add(wordCardKind);
|
||||||
return true;
|
log.warn(
|
||||||
}
|
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||||
return (
|
|
||||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||||
|
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||||
|
*/
|
||||||
|
export function resolveWordCardKind(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: {
|
||||||
|
lapisEnabled: boolean;
|
||||||
|
kikuEnabled: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
},
|
||||||
|
): CardKind | null {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||||
|
if (wordCardKind === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||||
|
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||||
|
if (flagValue === null) {
|
||||||
|
// Note type has no flag field for the configured kind.
|
||||||
|
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (flagValue.trim().length > 0) {
|
||||||
|
return wordCardKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||||
|
(fieldName) =>
|
||||||
|
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||||
|
);
|
||||||
|
return alreadyExplicitCard ? null : wordCardKind;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import type { CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
assert.equal(cardKind, 'word-and-sentence');
|
applyCardKindFlagFields(
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
updatedFields,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
cardKind,
|
||||||
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'click',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: 'x' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'none',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||||
|
...(config.lapisKiku ?? {}),
|
||||||
|
},
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
|||||||
patch.isKiku !== undefined
|
patch.isKiku !== undefined
|
||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
|
lapisKiku:
|
||||||
|
patch.lapisKiku !== undefined
|
||||||
|
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||||
|
: this.config.lapisKiku,
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -399,6 +399,30 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
||||||
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseArgs([
|
||||||
|
'--stats',
|
||||||
|
'--stats-cleanup',
|
||||||
|
'--stats-cleanup-duplicate-lines',
|
||||||
|
'--stats-cleanup-lookback-days',
|
||||||
|
'0.5',
|
||||||
|
]),
|
||||||
|
/at least one day/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseArgs([
|
||||||
|
'--stats',
|
||||||
|
'--stats-cleanup',
|
||||||
|
'--stats-cleanup-duplicate-lines',
|
||||||
|
'--stats-cleanup-lookback-days',
|
||||||
|
'1.5',
|
||||||
|
]).statsCleanupLookbackDays,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(parseArgs(['--stats-cleanup-lookback-days=30']).statsCleanupLookbackDays, 30);
|
||||||
|
assert.throws(() => parseArgs(['--stats-cleanup-lookback-days=30=oops']), /at least one day/);
|
||||||
|
|
||||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||||
|
|||||||
+22
-1
@@ -64,6 +64,9 @@ export interface CliArgs {
|
|||||||
statsCleanup?: boolean;
|
statsCleanup?: boolean;
|
||||||
statsCleanupVocab?: boolean;
|
statsCleanupVocab?: boolean;
|
||||||
statsCleanupLifetime?: boolean;
|
statsCleanupLifetime?: boolean;
|
||||||
|
statsCleanupDuplicateLines?: boolean;
|
||||||
|
statsCleanupDryRun?: boolean;
|
||||||
|
statsCleanupLookbackDays?: number;
|
||||||
statsResponsePath?: string;
|
statsResponsePath?: string;
|
||||||
jellyfin: boolean;
|
jellyfin: boolean;
|
||||||
jellyfinLogin: boolean;
|
jellyfinLogin: boolean;
|
||||||
@@ -109,6 +112,14 @@ export interface CliArgs {
|
|||||||
|
|
||||||
export type CliCommandSource = 'initial' | 'second-instance';
|
export type CliCommandSource = 'initial' | 'second-instance';
|
||||||
|
|
||||||
|
function parseStatsCleanupLookbackDays(value: string | undefined): number {
|
||||||
|
const days = Number(value);
|
||||||
|
if (!Number.isFinite(days) || days < 1) {
|
||||||
|
throw new Error('Stats --lookback-days must be at least one day.');
|
||||||
|
}
|
||||||
|
return Math.floor(days);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const args: CliArgs = {
|
const args: CliArgs = {
|
||||||
background: false,
|
background: false,
|
||||||
@@ -167,6 +178,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
|
statsCleanupDuplicateLines: false,
|
||||||
|
statsCleanupDryRun: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
@@ -368,7 +381,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
||||||
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
||||||
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
||||||
else if (arg.startsWith('--stats-response-path=')) {
|
else if (arg === '--stats-cleanup-duplicate-lines') args.statsCleanupDuplicateLines = true;
|
||||||
|
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
||||||
|
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
||||||
|
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(
|
||||||
|
arg.slice('--stats-cleanup-lookback-days='.length),
|
||||||
|
);
|
||||||
|
} else if (arg === '--stats-cleanup-lookback-days') {
|
||||||
|
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(readValue(argv[i + 1]));
|
||||||
|
} else if (arg.startsWith('--stats-response-path=')) {
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
if (value) args.statsResponsePath = value;
|
if (value) args.statsResponsePath = value;
|
||||||
} else if (arg === '--stats-response-path') {
|
} else if (arg === '--stats-response-path') {
|
||||||
|
|||||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": { "wordCardKind": "click" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||||
|
assert.equal(service.getWarnings().length, 0);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fieldGrouping: 'disabled',
|
fieldGrouping: 'disabled',
|
||||||
deleteDuplicateInAuto: true,
|
deleteDuplicateInAuto: true,
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'word-and-sentence',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jimaku: {
|
jimaku: {
|
||||||
apiBaseUrl: 'https://jimaku.cc',
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_VALUES,
|
NOTIFICATION_TYPE_VALUES,
|
||||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||||
description: 'Note type name used by Lapis sentence cards.',
|
description: 'Note type name used by Lapis sentence cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: WORD_CARD_KINDS,
|
||||||
|
enumLabels: {
|
||||||
|
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||||
|
click: 'Click card (IsClickCard)',
|
||||||
|
sentence: 'Sentence card (IsSentenceCard)',
|
||||||
|
audio: 'Audio card (IsAudioCard)',
|
||||||
|
none: 'Leave card type flags untouched',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
description:
|
||||||
|
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.metadata.pattern',
|
path: 'ankiConnect.metadata.pattern',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
applyAnkiKikuResolution(context);
|
applyAnkiKikuResolution(context);
|
||||||
|
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
|||||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...context.resolved.ankiConnect.lapisKiku,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAnkiLapisKikuResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!isObject(ankiConnect.lapisKiku)) {
|
||||||
|
if (ankiConnect.lapisKiku !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku',
|
||||||
|
ankiConnect.lapisKiku,
|
||||||
|
context.resolved.ankiConnect.lapisKiku,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
if (wordCardKind === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWordCardKind(wordCardKind)) {
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
wordCardKind,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
}
|
||||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||||
'ankiConnect.isLapis.sentenceCardModel':
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind':
|
||||||
|
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||||
'subtitleStyle.css':
|
'subtitleStyle.css':
|
||||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleStyle.secondary.css':
|
'subtitleStyle.secondary.css':
|
||||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media Capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (
|
||||||
|
path.startsWith('ankiConnect.isKiku.') ||
|
||||||
|
path.startsWith('ankiConnect.isLapis.') ||
|
||||||
|
path.startsWith('ankiConnect.lapisKiku.')
|
||||||
|
) {
|
||||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||||
path === 'mpv.aniskipEnabled' ||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
|
|||||||
@@ -1032,6 +1032,180 @@ describe('stats server API routes', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/maintenance/duplicate-lines forwards the window and dry-run flag', async () => {
|
||||||
|
let seenOptions: unknown = null;
|
||||||
|
const summary = {
|
||||||
|
dryRun: true,
|
||||||
|
lookbackDays: 30,
|
||||||
|
scannedLines: 900,
|
||||||
|
burstGroups: 2,
|
||||||
|
removedLines: 180,
|
||||||
|
removedWordOccurrences: 540,
|
||||||
|
removedKanjiOccurrences: 120,
|
||||||
|
samples: [],
|
||||||
|
};
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||||
|
seenOptions = options;
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dryRun: true, lookbackDays: 30 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), summary);
|
||||||
|
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/maintenance/duplicate-lines rejects cross-origin simple requests', async () => {
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async () => {
|
||||||
|
cleanupCalls += 1;
|
||||||
|
throw new Error('cleanup must not run');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
Origin: 'https://attacker.example',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 415);
|
||||||
|
assert.equal(cleanupCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async () => {
|
||||||
|
cleanupCalls += 1;
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
lookbackDays: null,
|
||||||
|
scannedLines: 0,
|
||||||
|
burstGroups: 0,
|
||||||
|
removedLines: 0,
|
||||||
|
removedWordOccurrences: 0,
|
||||||
|
removedKanjiOccurrences: 0,
|
||||||
|
samples: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(cleanupCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => {
|
||||||
|
let seenOptions: unknown = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||||
|
seenOptions = options;
|
||||||
|
return {
|
||||||
|
dryRun: true,
|
||||||
|
lookbackDays: 1,
|
||||||
|
scannedLines: 0,
|
||||||
|
burstGroups: 0,
|
||||||
|
removedLines: 0,
|
||||||
|
removedWordOccurrences: 0,
|
||||||
|
removedKanjiOccurrences: 0,
|
||||||
|
samples: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => {
|
||||||
|
let seenOptions: unknown = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||||
|
seenOptions = options;
|
||||||
|
return {
|
||||||
|
dryRun: false,
|
||||||
|
lookbackDays: null,
|
||||||
|
scannedLines: 0,
|
||||||
|
burstGroups: 0,
|
||||||
|
removedLines: 0,
|
||||||
|
removedWordOccurrences: 0,
|
||||||
|
removedKanjiOccurrences: 0,
|
||||||
|
samples: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const malformed of [
|
||||||
|
{ name: 'a missing body', body: undefined },
|
||||||
|
{ name: 'malformed JSON', body: '{' },
|
||||||
|
{ name: 'JSON null', body: 'null' },
|
||||||
|
{ name: 'a JSON array', body: '[]' },
|
||||||
|
]) {
|
||||||
|
it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => {
|
||||||
|
let cleanupCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
cleanupDuplicateSubtitleLines: async () => {
|
||||||
|
cleanupCalls += 1;
|
||||||
|
throw new Error('cleanup must not run');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: malformed.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(cleanupCalls, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
|
|
||||||
@@ -1100,12 +1274,56 @@ describe('stats server API routes', () => {
|
|||||||
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
assert.deepEqual(assignments, [{ animeId: 1, body }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
it('GET /api/stats/anime/:animeId/cover returns revalidated cover art', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1/cover');
|
const res = await app.request('/api/stats/anime/1/cover');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(res.headers.get('content-type'), 'image/jpeg');
|
assert.equal(res.headers.get('content-type'), 'image/jpeg');
|
||||||
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
|
assert.equal(res.headers.get('cache-control'), 'no-cache');
|
||||||
|
assert.ok(res.headers.get('etag'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/anime/:animeId/cover answers 304 for a matching ETag', async () => {
|
||||||
|
const app = createStatsApp(createMockTracker());
|
||||||
|
const first = await app.request('/api/stats/anime/1/cover');
|
||||||
|
const etag = first.headers.get('etag');
|
||||||
|
assert.ok(etag);
|
||||||
|
|
||||||
|
const cached = await app.request('/api/stats/anime/1/cover', {
|
||||||
|
headers: { 'If-None-Match': `W/${etag}, "other"` },
|
||||||
|
});
|
||||||
|
assert.equal(cached.status, 304);
|
||||||
|
assert.equal(cached.headers.get('etag'), etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
|
||||||
|
// A relinked AniList entry swaps the bytes behind the same cover URL.
|
||||||
|
let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getAnimeCoverArt: async () => ({
|
||||||
|
videoId: 1,
|
||||||
|
anilistId: 21858,
|
||||||
|
coverUrl: 'https://example.com/cover.jpg',
|
||||||
|
coverBlob,
|
||||||
|
titleRomaji: 'Little Witch Academia',
|
||||||
|
titleEnglish: 'Little Witch Academia',
|
||||||
|
episodesTotal: 25,
|
||||||
|
fetchedAtMs: Date.now(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const first = await app.request('/api/stats/anime/1/cover');
|
||||||
|
const staleEtag = first.headers.get('etag');
|
||||||
|
assert.ok(staleEtag);
|
||||||
|
|
||||||
|
coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const res = await app.request('/api/stats/anime/1/cover', {
|
||||||
|
headers: { 'If-None-Match': staleEtag },
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get('content-type'), 'image/png');
|
||||||
|
assert.notEqual(res.headers.get('etag'), staleEtag);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
|
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
|
||||||
@@ -2410,6 +2628,80 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.IsClickCard, 'x');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -2873,6 +3165,39 @@ Aligned English subtitle
|
|||||||
assert.equal(deleteCalls, 0);
|
assert.equal(deleteCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
|
||||||
|
let deletedAnimeId: number | null = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteAnime: async (animeId: number) => {
|
||||||
|
deletedAnimeId = animeId;
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(deletedAnimeId, 7);
|
||||||
|
assert.deepEqual(await res.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
|
||||||
|
let deleteCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
deleteAnime: async () => {
|
||||||
|
deleteCalls += 1;
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
|
||||||
|
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(deleteCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||||
|
|||||||
@@ -143,3 +143,50 @@ test('anilist update queue persists and reloads from disk', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('drops queued items whose persisted mediaId is not a positive integer', () => {
|
||||||
|
const filePath = path.join(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'anilist-queue-mediaid-')),
|
||||||
|
'queue.json',
|
||||||
|
);
|
||||||
|
const base = {
|
||||||
|
episode: 1,
|
||||||
|
createdAt: 1,
|
||||||
|
attemptCount: 0,
|
||||||
|
nextAttemptAt: 0,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify({
|
||||||
|
pending: [
|
||||||
|
{ ...base, key: 'ok-absent', title: 'A' },
|
||||||
|
{ ...base, key: 'ok-null', title: 'B', mediaId: null },
|
||||||
|
{ ...base, key: 'ok-valid', title: 'C', mediaId: 108489 },
|
||||||
|
{ ...base, key: 'bad-string', title: 'D', mediaId: '108489' },
|
||||||
|
{ ...base, key: 'bad-zero', title: 'E', mediaId: 0 },
|
||||||
|
{ ...base, key: 'bad-negative', title: 'F', mediaId: -3 },
|
||||||
|
{ ...base, key: 'bad-float', title: 'G', mediaId: 1.5 },
|
||||||
|
{ ...base, key: 'bad-object', title: 'H', mediaId: { id: 1 } },
|
||||||
|
],
|
||||||
|
deadLetter: [],
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const queue = createAnilistUpdateQueue(filePath, {
|
||||||
|
info: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const keptKeys: string[] = [];
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const next = queue.nextReady(1000);
|
||||||
|
if (!next) break;
|
||||||
|
keptKeys.push(next.key);
|
||||||
|
queue.markSuccess(next.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(keptKeys, ['ok-absent', 'ok-null', 'ok-valid']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface AnilistQueuedUpdate {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
season?: number | null;
|
season?: number | null;
|
||||||
|
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
|
||||||
|
mediaId?: number | null;
|
||||||
episode: number;
|
episode: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -29,13 +31,31 @@ export interface AnilistRetryQueueSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnilistUpdateQueue {
|
export interface AnilistUpdateQueue {
|
||||||
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
enqueue: (
|
||||||
|
key: string,
|
||||||
|
title: string,
|
||||||
|
episode: number,
|
||||||
|
season?: number | null,
|
||||||
|
mediaId?: number | null,
|
||||||
|
) => void;
|
||||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||||
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persisted mediaId pins the update to a specific AniList entry, so a corrupted or
|
||||||
|
* hand-edited queue file must not be able to feed a bogus id straight to the mutation.
|
||||||
|
*/
|
||||||
|
function isValidPersistedMediaId(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
value === null ||
|
||||||
|
(typeof value === 'number' && Number.isInteger(value) && value > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function clampBackoffMs(attemptCount: number): number {
|
function clampBackoffMs(attemptCount: number): number {
|
||||||
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
||||||
return Math.min(MAX_BACKOFF_MS, computed);
|
return Math.min(MAX_BACKOFF_MS, computed);
|
||||||
@@ -82,6 +102,7 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
|
isValidPersistedMediaId(item.mediaId) &&
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -96,6 +117,7 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
|
isValidPersistedMediaId(item.mediaId) &&
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -107,7 +129,13 @@ export function createAnilistUpdateQueue(
|
|||||||
load();
|
load();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
enqueue(
|
||||||
|
key: string,
|
||||||
|
title: string,
|
||||||
|
episode: number,
|
||||||
|
season: number | null = null,
|
||||||
|
mediaId: number | null = null,
|
||||||
|
): void {
|
||||||
const existing =
|
const existing =
|
||||||
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -120,6 +148,7 @@ export function createAnilistUpdateQueue(
|
|||||||
key,
|
key,
|
||||||
title,
|
title,
|
||||||
season,
|
season,
|
||||||
|
mediaId,
|
||||||
episode,
|
episode,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user