mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d182ccea
|
||
|
|
504e15ae0e
|
||
|
|
66bf0db0fc
|
||
|
|
e22117fe83
|
||
|
|
1b1b062803
|
||
|
|
2174e689a2 | ||
|
|
7d729cb60c | ||
|
|
d963def1a9 | ||
|
|
6fb3d2eb13 | ||
|
|
c5a77ac067 | ||
|
|
8ddc151435
|
||
|
|
b98d4d65c7 | ||
|
|
046e74ea91
|
||
|
|
8bf847503d | ||
|
|
47b5903392
|
||
|
|
bf85554d1e | ||
|
|
d74c7e1235
|
||
|
|
57ddd19953 | ||
|
|
ee25536d90 | ||
|
|
7b0fbdf254 | ||
|
|
2fefc83e3f | ||
|
|
dbdf578c68 | ||
|
|
441ecf3c04 | ||
|
|
a0dde4ee3e
|
||
|
|
fe4dacc1e7 | ||
|
|
b08cd0db35 | ||
|
|
bffb1c5982 | ||
|
|
5b8848518a | ||
|
|
176edd67f1
|
||
|
|
4d65dec340 | ||
|
|
6607c333bc
|
||
|
|
b2bbf1ae12
|
||
|
|
b204d4dd6e | ||
|
|
89ed675935 |
@@ -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,56 @@
|
|||||||
# 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)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed native Wayland drag-and-drop from file managers such as Thunar so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: documentation
|
||||||
|
|
||||||
|
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: added
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
||||||
|
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
|
||||||
|
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: notifications
|
||||||
|
|
||||||
|
- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: mining
|
||||||
|
|
||||||
|
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and explicit cancellation choices.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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.
|
||||||
|
- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement.
|
||||||
@@ -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/reviewTiming, 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.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,6 +569,7 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
|
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
@@ -605,7 +606,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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
"normalizeAudio": true, // normalize generated clip loudness
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||||
|
"reviewTiming": false, // review and adjust timing before media generation
|
||||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||||
"maxMediaDuration": 30 // cap total duration in seconds
|
"maxMediaDuration": 30 // cap total duration in seconds
|
||||||
}
|
}
|
||||||
@@ -178,6 +179,10 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
|||||||
|
|
||||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||||
|
|
||||||
|
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels; buttons reveal another five seconds before or after the visible timeline. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
||||||
|
|
||||||
|
Canceling the review lets you keep editing, finish with the original timing, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
||||||
|
|
||||||
### Screenshots (Static)
|
### Screenshots (Static)
|
||||||
|
|
||||||
A single frame is captured at the current playback position.
|
A single frame is captured at the current playback position.
|
||||||
@@ -289,6 +294,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 +333,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, ...)
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
# 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)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
+76
-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.
|
||||||
|
|
||||||
@@ -967,6 +967,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"normalizeAudio": true,
|
"normalizeAudio": true,
|
||||||
"mirrorMpvVolume": true,
|
"mirrorMpvVolume": true,
|
||||||
|
"reviewTiming": false,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"maxMediaDuration": 30
|
||||||
@@ -1019,6 +1020,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||||
|
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
@@ -1043,7 +1045,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 +1071,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 +1082,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 +1187,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 +1198,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 +1248,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 +1559,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,13 @@ 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.
|
||||||
|
|
||||||
|
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
||||||
|
|
||||||
|
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||||
|
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||||
|
|
||||||
|
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||

|

|
||||||
@@ -125,6 +132,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:
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function slugify(heading: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
const EXCLUDED_PAGES = new Set(['README.md']);
|
||||||
|
const UNLISTED_ROUTES = new Set(['/demos']);
|
||||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
||||||
|
|
||||||
function loadPages(): Map<string, string> {
|
function loadPages(): Map<string, string> {
|
||||||
@@ -114,7 +115,7 @@ test('slugify matches the VitePress cases these docs actually rely on', () => {
|
|||||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('every docs page is reachable from the sidebar', async () => {
|
test('every docs page is reachable from the sidebar unless explicitly unlisted', async () => {
|
||||||
const { default: config } = await import('./.vitepress/config');
|
const { default: config } = await import('./.vitepress/config');
|
||||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
const sidebar = config.themeConfig?.sidebar as Array<{
|
||||||
items?: Array<{ text: string; link?: string }>;
|
items?: Array<{ text: string; link?: string }>;
|
||||||
@@ -127,6 +128,8 @@ test('every docs page is reachable from the sidebar', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
const orphans = [...pages.keys()].filter(
|
||||||
|
(route) => !linked.has(route) && !UNLISTED_ROUTES.has(route),
|
||||||
|
);
|
||||||
expect(orphans).toEqual([]);
|
expect(orphans).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/reviewTiming, 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.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,6 +569,7 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
|
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
@@ -605,7 +606,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,8 @@ 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.
|
||||||
|
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||||
|
`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', () => {
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
|||||||
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
withWritableDb(remotePath, (db) => {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_key = 'showb-e1'`,
|
||||||
|
).run();
|
||||||
|
});
|
||||||
|
|
||||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||||
assert.equal(summary.sessionsMerged, 1);
|
assert.equal(summary.sessionsMerged, 1);
|
||||||
@@ -126,6 +131,14 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
|||||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
||||||
)?.video_id,
|
)?.video_id,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
queryOne<{ locked: number }>(
|
||||||
|
localPath,
|
||||||
|
'SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?',
|
||||||
|
[mergedVideoId],
|
||||||
|
)?.locked,
|
||||||
|
1,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
||||||
mergedVideoId,
|
mergedVideoId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Schema-version-18 shape of the tables the sync merge touches (plus the
|
// Current schema shape of the tables the sync merge touches (plus the
|
||||||
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
||||||
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
||||||
export const IMMERSION_DB_FIXTURE_DDL = `
|
export const IMMERSION_DB_FIXTURE_DDL = `
|
||||||
@@ -39,6 +39,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
parser_source TEXT,
|
parser_source TEXT,
|
||||||
parser_confidence REAL,
|
parser_confidence REAL,
|
||||||
parse_metadata_json TEXT,
|
parse_metadata_json TEXT,
|
||||||
|
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
|
||||||
watched INTEGER NOT NULL DEFAULT 0,
|
watched INTEGER NOT NULL DEFAULT 0,
|
||||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||||
|
|||||||
@@ -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",
|
"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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Added
|
|
||||||
- **Anki Maturity Known-Word Highlighting**
|
|
||||||
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
|
|
||||||
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
|
|
||||||
- **Cross-Machine Sync for Stats & Watch History**
|
|
||||||
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
|
|
||||||
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
|
|
||||||
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
|
|
||||||
- **History Menu After Playback**
|
|
||||||
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
|
|
||||||
- **Delete Entire Library Titles from Stats**
|
|
||||||
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
|
|
||||||
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
|
|
||||||
- **TsukiHime English Subtitle Downloads**
|
|
||||||
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Configurable Clipboard-Video Shortcut**
|
|
||||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **AniList Season Matching**
|
|
||||||
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
|
|
||||||
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
|
|
||||||
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
|
|
||||||
- **Subtitle Highlighting Accuracy**
|
|
||||||
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
|
|
||||||
- **AnkiConnect Proxy Port Conflicts**
|
|
||||||
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
|
|
||||||
- **AppImage Crash Notification on Quit**
|
|
||||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
|
||||||
- **Startup Playback Pause Timing**
|
|
||||||
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
|
|
||||||
- **Stats Library Cover After Relinking**
|
|
||||||
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
|
|
||||||
- **Faster Stats Deletes and Vocabulary Tab**
|
|
||||||
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
|
|
||||||
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
|
|
||||||
- **Settings Validation and Stats Server Hardening**
|
|
||||||
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
|
|
||||||
- **Rofi Prompt Spacing**
|
|
||||||
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
|
||||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
|
||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
|
||||||
- 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
|
|
||||||
- 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
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+47
-76
@@ -22,10 +22,14 @@ 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,
|
||||||
|
type MediaTimingReviewDecision,
|
||||||
|
type MediaTimingReviewRequest,
|
||||||
} 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';
|
||||||
@@ -40,6 +44,7 @@ import {
|
|||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from './anki-field-config';
|
} from './anki-field-config';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
|
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||||
import {
|
import {
|
||||||
createUiFeedbackState,
|
createUiFeedbackState,
|
||||||
beginUpdateProgress,
|
beginUpdateProgress,
|
||||||
@@ -50,6 +55,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 +89,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();
|
||||||
@@ -236,6 +240,9 @@ export class AnkiIntegration {
|
|||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
|
private mediaTimingReviewCallback:
|
||||||
|
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
||||||
|
| null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||||
@@ -507,6 +514,7 @@ export class AnkiIntegration {
|
|||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||||
|
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: (
|
generateAudio: (
|
||||||
@@ -564,6 +572,7 @@ export class AnkiIntegration {
|
|||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
|
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||||
isUpdateInProgress: () => this.updateInProgress,
|
isUpdateInProgress: () => this.updateInProgress,
|
||||||
setUpdateInProgress: (value) => {
|
setUpdateInProgress: (value) => {
|
||||||
this.updateInProgress = value;
|
this.updateInProgress = value;
|
||||||
@@ -579,6 +588,7 @@ export class AnkiIntegration {
|
|||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
|
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,12 +645,14 @@ export class AnkiIntegration {
|
|||||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||||
|
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
||||||
},
|
},
|
||||||
getConfig: () => this.config,
|
getConfig: () => this.config,
|
||||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
|
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
||||||
extractFields: (fields) => this.extractFields(fields),
|
extractFields: (fields) => this.extractFields(fields),
|
||||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||||
@@ -668,6 +680,7 @@ export class AnkiIntegration {
|
|||||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||||
|
captureSubtitleMediaContext: () => captureLiveSubtitleMiningContext(this.mpvClient),
|
||||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
||||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||||
@@ -677,6 +690,7 @@ export class AnkiIntegration {
|
|||||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||||
|
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,6 +810,12 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeKnownWordNote(noteId: number): void {
|
||||||
|
if (this.knownWordCache.removeNote(noteId)) {
|
||||||
|
this.notifyKnownWordCacheUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private notifyKnownWordCacheUpdated(): void {
|
private notifyKnownWordCacheUpdated(): void {
|
||||||
if (!this.knownWordCacheUpdatedCallback) {
|
if (!this.knownWordCacheUpdatedCallback) {
|
||||||
return;
|
return;
|
||||||
@@ -840,6 +860,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 +873,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,7 +1056,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.config.media?.audioPadding,
|
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
this.config.media?.normalizeAudio !== false,
|
this.config.media?.normalizeAudio !== false,
|
||||||
await this.getMpvVolumeScale(),
|
await this.getMpvVolumeScale(),
|
||||||
@@ -1067,7 +1089,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
mediaRange.startTime,
|
mediaRange.startTime,
|
||||||
mediaRange.endTime,
|
mediaRange.endTime,
|
||||||
this.config.media?.audioPadding,
|
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.config.media?.animatedFps,
|
fps: this.config.media?.animatedFps,
|
||||||
maxWidth: this.config.media?.animatedMaxWidth,
|
maxWidth: this.config.media?.animatedMaxWidth,
|
||||||
@@ -1315,79 +1337,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(
|
||||||
@@ -1788,6 +1740,25 @@ export class AnkiIntegration {
|
|||||||
this.consumeSubtitleMiningContextCallback = callback;
|
this.consumeSubtitleMiningContextCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMediaTimingReviewCallback(
|
||||||
|
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
||||||
|
): void {
|
||||||
|
this.mediaTimingReviewCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reviewMediaTiming(
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
): Promise<MediaTimingReviewDecision> {
|
||||||
|
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
||||||
|
return { action: 'use-original' };
|
||||||
|
}
|
||||||
|
return await this.mediaTimingReviewCallback({
|
||||||
|
...request,
|
||||||
|
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
|
||||||
|
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
resolveCurrentNoteId(noteId: number): number {
|
resolveCurrentNoteId(noteId: number): number {
|
||||||
let resolved = noteId;
|
let resolved = noteId;
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|||||||
@@ -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> = {}): {
|
||||||
@@ -91,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => Buffer.from('audio'),
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
@@ -135,6 +130,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -207,6 +203,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
model: 'Sentence',
|
model: 'Sentence',
|
||||||
@@ -217,7 +214,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('字幕');
|
||||||
@@ -254,6 +251,7 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,6 +339,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -389,3 +388,47 @@ test('createSentenceCard relies on Anki progress notification without standalone
|
|||||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||||
assert.deepEqual(statusMessages, []);
|
assert.deepEqual(statusMessages, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const statusMessages: string[] = [];
|
||||||
|
const { service } = createManualUpdateService({
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentSubText: '字幕',
|
||||||
|
currentSubStart: 4,
|
||||||
|
currentSubEnd: 6,
|
||||||
|
currentTimePos: 5,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 0,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: { Expression: { value: '単語' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async () => undefined,
|
||||||
|
storeMediaFile: async () => undefined,
|
||||||
|
findNotes: async () => [42],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async (noteIds) => {
|
||||||
|
events.push(`delete:${noteIds.join(',')}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewMediaTiming: async () => ({ action: 'discard' }),
|
||||||
|
removeKnownWordNote: (noteId) => {
|
||||||
|
events.push(`cache:${noteId}`);
|
||||||
|
},
|
||||||
|
showStatusNotification: (message) => {
|
||||||
|
statusMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.markLastCardAsAudioCard();
|
||||||
|
|
||||||
|
assert.deepEqual(events, ['delete:42', 'cache:42']);
|
||||||
|
assert.deepEqual(statusMessages, ['Card deleted.']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
const storedMedia: string[] = [];
|
const storedMedia: string[] = [];
|
||||||
const requestedProperties: string[] = [];
|
const requestedProperties: string[] = [];
|
||||||
const audioVolumeScales: Array<number | undefined> = [];
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
|
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
||||||
|
|
||||||
const deps: CardCreationDeps = {
|
const deps: CardCreationDeps = {
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
},
|
},
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (
|
generateAudio: async (
|
||||||
_path,
|
_path,
|
||||||
_startTime,
|
startTime,
|
||||||
_endTime,
|
endTime,
|
||||||
_audioPadding,
|
audioPadding,
|
||||||
_audioStreamIndex,
|
_audioStreamIndex,
|
||||||
_normalizeAudio,
|
_normalizeAudio,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
) => {
|
) => {
|
||||||
|
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
||||||
audioVolumeScales.push(volumeScale);
|
audioVolumeScales.push(volumeScale);
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
@@ -122,17 +125,15 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const created = await new CardCreationService(deps).createSentenceCard(
|
const service = new CardCreationService(deps);
|
||||||
'字幕',
|
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
||||||
12,
|
|
||||||
14,
|
|
||||||
'Subtitle',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(created, true);
|
||||||
assert.deepEqual(addedFields[0], {
|
assert.deepEqual(addedFields[0], {
|
||||||
@@ -144,7 +145,12 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
assert.deepEqual(requestedProperties, ['volume']);
|
assert.deepEqual(requestedProperties, ['volume']);
|
||||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||||
|
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
||||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||||
|
|
||||||
|
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
||||||
|
assert.equal(addedFields.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -74,6 +75,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -139,6 +141,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -173,6 +176,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => {
|
trackLastAddedNoteId: () => {
|
||||||
@@ -238,6 +242,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -272,6 +277,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
recordCardsMinedCallback: () => {
|
recordCardsMinedCallback: () => {
|
||||||
@@ -348,6 +354,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -392,6 +399,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -454,6 +462,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||||
@@ -495,6 +504,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -590,6 +600,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => {
|
generateAudio: async () => {
|
||||||
@@ -634,6 +645,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -701,6 +713,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -733,6 +746,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -790,6 +804,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -822,6 +837,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
removeKnownWordNote: () => undefined,
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import {
|
||||||
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
|
type MediaTimingReviewDecision,
|
||||||
|
type MediaTimingReviewRequest,
|
||||||
|
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 +21,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 +48,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,
|
||||||
@@ -57,6 +61,7 @@ interface CardCreationClient {
|
|||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||||
retrieveMediaFile(filename: string): Promise<string>;
|
retrieveMediaFile(filename: string): Promise<string>;
|
||||||
|
deleteNotes(noteIds: number[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardCreationMediaGenerator {
|
interface CardCreationMediaGenerator {
|
||||||
@@ -136,15 +141,20 @@ 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;
|
||||||
|
removeKnownWordNote: (noteId: number) => void;
|
||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
|
reviewMediaTiming?: (
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
) => Promise<MediaTimingReviewDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardCreationService {
|
export class CardCreationService {
|
||||||
@@ -261,6 +271,7 @@ export class CardCreationService {
|
|||||||
fields,
|
fields,
|
||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
const sentenceField = sentenceCardConfig.sentenceField;
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
@@ -274,12 +285,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;
|
||||||
}
|
}
|
||||||
@@ -455,6 +463,27 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'audio',
|
||||||
|
text: mpvClient.currentSubText,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
noteId,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
await this.deps.client.deleteNotes([noteId]);
|
||||||
|
this.deps.removeKnownWordNote(noteId);
|
||||||
|
this.deps.showStatusNotification('Card deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
startTime = timingDecision.startTime;
|
||||||
|
endTime = timingDecision.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let miscInfoFilename: string | null = null;
|
let miscInfoFilename: string | null = null;
|
||||||
@@ -475,6 +504,7 @@ export class CardCreationService {
|
|||||||
mpvClient.currentVideoPath,
|
mpvClient.currentVideoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
exactReviewedRange ? 0 : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -496,6 +526,7 @@ export class CardCreationService {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
|
exactReviewedRange,
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageField = this.deps.getConfig().fields?.image;
|
const imageField = this.deps.getConfig().fields?.image;
|
||||||
@@ -568,6 +599,24 @@ export class CardCreationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'sentence',
|
||||||
|
text: sentence,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
this.deps.showStatusNotification('Card creation cancelled.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const exactReviewedRange = timingDecision.action === 'confirm';
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
startTime = timingDecision.startTime;
|
||||||
|
endTime = timingDecision.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
const generateAudio = shouldGenerateAudio(config);
|
const generateAudio = shouldGenerateAudio(config);
|
||||||
const generateImage = shouldGenerateImage(config);
|
const generateImage = shouldGenerateImage(config);
|
||||||
@@ -736,6 +785,7 @@ export class CardCreationService {
|
|||||||
generateAudio,
|
generateAudio,
|
||||||
generateImage,
|
generateImage,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
|
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
|
||||||
});
|
});
|
||||||
await this.deps.showNotification(noteId, label, 'media queued');
|
await this.deps.showNotification(noteId, label, 'media queued');
|
||||||
return true;
|
return true;
|
||||||
@@ -751,7 +801,12 @@ export class CardCreationService {
|
|||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
const audioFilename = this.generateAudioFilename();
|
||||||
const audioBuffer = audioSourcePath
|
const audioBuffer = audioSourcePath
|
||||||
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
? await this.mediaGenerateAudio(
|
||||||
|
audioSourcePath,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
exactReviewedRange ? 0 : undefined,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -769,7 +824,13 @@ export class CardCreationService {
|
|||||||
if (generateImage) {
|
if (generateImage) {
|
||||||
try {
|
try {
|
||||||
const imageFilename = this.generateImageFilename();
|
const imageFilename = this.generateImageFilename();
|
||||||
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
const imageBuffer = await this.generateImageBuffer(
|
||||||
|
videoPath!,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
0,
|
||||||
|
exactReviewedRange,
|
||||||
|
);
|
||||||
|
|
||||||
const imageField = config.fields?.image;
|
const imageField = config.fields?.image;
|
||||||
if (imageBuffer && imageField) {
|
if (imageBuffer && imageField) {
|
||||||
@@ -837,6 +898,7 @@ export class CardCreationService {
|
|||||||
videoPath: MediaInput,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
|
audioPaddingOverride?: number,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
@@ -847,7 +909,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
this.deps.getConfig().media?.audioPadding,
|
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(
|
resolveAudioStreamIndexForMediaGeneration(
|
||||||
videoPath,
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
@@ -865,13 +927,16 @@ export class CardCreationService {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
|
exactReviewedRange = false,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = mpvClient.currentTimePos || 0;
|
const timestamp = exactReviewedRange
|
||||||
|
? startTime + (endTime - startTime) / 2
|
||||||
|
: mpvClient.currentTimePos || 0;
|
||||||
|
|
||||||
if (this.deps.getConfig().media?.imageType === 'avif') {
|
if (this.deps.getConfig().media?.imageType === 'avif') {
|
||||||
let imageStart = startTime;
|
let imageStart = startTime;
|
||||||
@@ -887,7 +952,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
imageStart,
|
imageStart,
|
||||||
imageEnd,
|
imageEnd,
|
||||||
this.deps.getConfig().media?.audioPadding,
|
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.deps.getConfig().media?.animatedFps,
|
fps: this.deps.getConfig().media?.animatedFps,
|
||||||
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -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] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,32 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
|
||||||
|
const config: AnkiConnectConfig = {
|
||||||
|
fields: { word: 'Word' },
|
||||||
|
knownWords: { highlightEnabled: true },
|
||||||
|
};
|
||||||
|
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.appendFromNoteInfo({
|
||||||
|
noteId: 42,
|
||||||
|
fields: { Word: { value: '猫' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(manager.removeNote(42), true);
|
||||||
|
assert.equal(manager.removeNote(42), false);
|
||||||
|
assert.equal(manager.isKnownWord('猫'), false);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||||
|
notes?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(persisted.notes, {});
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeNote(noteId: number): boolean {
|
||||||
|
if (!this.noteEntriesById.has(noteId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeNoteSnapshot(noteId);
|
||||||
|
this.persistKnownWordCacheState();
|
||||||
|
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
clearKnownWordCacheState(): void {
|
clearKnownWordCacheState(): void {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -49,6 +44,7 @@ function createWorkflowHarness() {
|
|||||||
updates.push({ noteId, fields });
|
updates.push({ noteId, fields });
|
||||||
},
|
},
|
||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
|
deleteNotes: async () => undefined,
|
||||||
},
|
},
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -66,6 +62,7 @@ function createWorkflowHarness() {
|
|||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||||
|
removeKnownWordNote: (_noteId: number) => undefined,
|
||||||
extractFields: (fields: Record<string, { value: string }>) => {
|
extractFields: (fields: Record<string, { value: string }>) => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -79,7 +76,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 +180,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 () =>
|
||||||
@@ -410,6 +474,71 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
|||||||
assert.equal(miscInfoStartTime, 10);
|
assert.equal(miscInfoStartTime, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow snapshots one media range for audio and image without a mining context', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
const capturedContext: SubtitleMiningContext = {
|
||||||
|
source: 'overlay',
|
||||||
|
text: 'subtitle-text',
|
||||||
|
startTime: 31.5,
|
||||||
|
endTime: 34.25,
|
||||||
|
};
|
||||||
|
let captureCalls = 0;
|
||||||
|
let audioContext: SubtitleMiningContext | null = null;
|
||||||
|
let imageContext: SubtitleMiningContext | null = null;
|
||||||
|
let miscInfoStartTime: number | undefined;
|
||||||
|
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
SentenceAudio: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
MiscInfo: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
harness.deps.getConfig = () => ({
|
||||||
|
fields: {
|
||||||
|
sentence: 'Sentence',
|
||||||
|
image: 'Picture',
|
||||||
|
miscInfo: 'MiscInfo',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: true,
|
||||||
|
imageType: 'avif',
|
||||||
|
},
|
||||||
|
behavior: {},
|
||||||
|
});
|
||||||
|
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => {
|
||||||
|
captureCalls += 1;
|
||||||
|
return capturedContext;
|
||||||
|
};
|
||||||
|
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
||||||
|
audioContext = context ?? null;
|
||||||
|
return Buffer.from('audio');
|
||||||
|
};
|
||||||
|
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
|
||||||
|
imageContext = context ?? null;
|
||||||
|
return Buffer.from('image');
|
||||||
|
};
|
||||||
|
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
|
||||||
|
miscInfoStartTime = startTimeSeconds;
|
||||||
|
return `start:${startTimeSeconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(captureCalls, 1);
|
||||||
|
assert.deepEqual(audioContext, capturedContext);
|
||||||
|
assert.deepEqual(imageContext, capturedContext);
|
||||||
|
assert.equal(miscInfoStartTime, 31.5);
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
|
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
const queuedUpdates: Array<{
|
const queuedUpdates: Array<{
|
||||||
@@ -465,3 +594,62 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
|
|||||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
const deletedNoteIds: number[][] = [];
|
||||||
|
const removedKnownWordNoteIds: number[] = [];
|
||||||
|
let appendedKnownWords = false;
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => ({
|
||||||
|
source: 'overlay',
|
||||||
|
text: 'subtitle-text',
|
||||||
|
startTime: 4,
|
||||||
|
endTime: 6,
|
||||||
|
});
|
||||||
|
harness.deps.client.deleteNotes = async (noteIds) => {
|
||||||
|
deletedNoteIds.push(noteIds);
|
||||||
|
};
|
||||||
|
harness.deps.appendKnownWordsFromNoteInfo = () => {
|
||||||
|
appendedKnownWords = true;
|
||||||
|
};
|
||||||
|
harness.deps.removeKnownWordNote = (noteId) => {
|
||||||
|
removedKnownWordNoteIds.push(noteId);
|
||||||
|
};
|
||||||
|
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.deepEqual(deletedNoteIds, [[42]]);
|
||||||
|
assert.deepEqual(removedKnownWordNoteIds, [42]);
|
||||||
|
assert.equal(appendedKnownWords, false);
|
||||||
|
assert.deepEqual(harness.updates, []);
|
||||||
|
assert.deepEqual(harness.notifications, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
const statusMessages: string[] = [];
|
||||||
|
let removedKnownWord = false;
|
||||||
|
harness.deps.captureSubtitleMediaContext = () => ({
|
||||||
|
source: 'overlay',
|
||||||
|
text: 'subtitle-text',
|
||||||
|
startTime: 4,
|
||||||
|
endTime: 6,
|
||||||
|
});
|
||||||
|
harness.deps.client.deleteNotes = async () => {
|
||||||
|
throw new Error('delete failed');
|
||||||
|
};
|
||||||
|
harness.deps.removeKnownWordNote = () => {
|
||||||
|
removedKnownWord = true;
|
||||||
|
};
|
||||||
|
harness.deps.showOsdNotification = (message) => {
|
||||||
|
statusMessages.push(message);
|
||||||
|
};
|
||||||
|
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(removedKnownWord, false);
|
||||||
|
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
||||||
|
assert.ok(harness.warnings.length === 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
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,
|
||||||
|
MediaTimingReviewDecision,
|
||||||
|
MediaTimingReviewRequest,
|
||||||
|
WordCardKind,
|
||||||
|
} from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -13,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
|
deleteNotes(noteIds: number[]): Promise<void>;
|
||||||
};
|
};
|
||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
fields?: {
|
fields?: {
|
||||||
@@ -39,8 +46,10 @@ 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;
|
||||||
|
removeKnownWordNote: (noteId: number) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
findDuplicateNote: (
|
findDuplicateNote: (
|
||||||
expression: string,
|
expression: string,
|
||||||
@@ -67,7 +76,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,
|
||||||
@@ -85,6 +94,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
) => Promise<Buffer | null>;
|
) => Promise<Buffer | null>;
|
||||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||||
|
captureSubtitleMediaContext?: () => SubtitleMiningContext | null;
|
||||||
queuePendingYoutubeMediaUpdate?: (job: {
|
queuePendingYoutubeMediaUpdate?: (job: {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo;
|
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||||
@@ -99,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logInfo: (message: string, ...args: unknown[]) => void;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
reviewMediaTiming?: (
|
||||||
|
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
||||||
|
) => Promise<MediaTimingReviewDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubtitleContextText(text: string): string {
|
function normalizeSubtitleContextText(text: string): string {
|
||||||
@@ -168,7 +181,6 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteInfo = notesInfo[0]!;
|
const noteInfo = notesInfo[0]!;
|
||||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
|
||||||
const fields = this.deps.extractFields(noteInfo.fields);
|
const fields = this.deps.extractFields(noteInfo.fields);
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
|
|
||||||
@@ -201,18 +213,55 @@ export class NoteUpdateWorkflow {
|
|||||||
sentenceField,
|
sentenceField,
|
||||||
config.fields?.sentence,
|
config.fields?.sentence,
|
||||||
);
|
);
|
||||||
|
// Audio and image generation run sequentially and audio extraction can take tens of
|
||||||
|
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||||
|
// timings per generator clips whichever line is on screen when each one starts.
|
||||||
|
let mediaTimingContext =
|
||||||
|
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||||
|
|
||||||
|
if (mediaTimingContext) {
|
||||||
|
const timingDecision = this.deps.reviewMediaTiming
|
||||||
|
? await this.deps.reviewMediaTiming({
|
||||||
|
kind: 'word',
|
||||||
|
text: mediaTimingContext.text,
|
||||||
|
startTime: mediaTimingContext.startTime,
|
||||||
|
endTime: mediaTimingContext.endTime,
|
||||||
|
noteId,
|
||||||
|
})
|
||||||
|
: ({ action: 'use-original' } as const);
|
||||||
|
if (timingDecision.action === 'discard') {
|
||||||
|
try {
|
||||||
|
await this.deps.client.deleteNotes([noteId]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.deps.logError('Failed to delete discarded card:', message);
|
||||||
|
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deps.removeKnownWordNote(noteId);
|
||||||
|
this.deps.showOsdNotification('Card deleted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timingDecision.action === 'confirm') {
|
||||||
|
mediaTimingContext = {
|
||||||
|
...mediaTimingContext,
|
||||||
|
startTime: timingDecision.startTime,
|
||||||
|
endTime: timingDecision.endTime,
|
||||||
|
mediaPaddingSeconds: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||||
|
|
||||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -241,7 +290,7 @@ export class NoteUpdateWorkflow {
|
|||||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||||
noteId,
|
noteId,
|
||||||
noteInfo,
|
noteInfo,
|
||||||
context: subtitleMiningContext ?? undefined,
|
context: mediaTimingContext ?? undefined,
|
||||||
label: noteLabel,
|
label: noteLabel,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
@@ -249,7 +298,7 @@ export class NoteUpdateWorkflow {
|
|||||||
if (!mediaCacheQueued && generateAudio) {
|
if (!mediaCacheQueued && generateAudio) {
|
||||||
try {
|
try {
|
||||||
const audioFilename = this.deps.generateAudioFilename();
|
const audioFilename = this.deps.generateAudioFilename();
|
||||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
const audioBuffer = await this.deps.generateAudio(mediaTimingContext ?? undefined);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
@@ -277,7 +326,7 @@ export class NoteUpdateWorkflow {
|
|||||||
const imageFilename = this.deps.generateImageFilename();
|
const imageFilename = this.deps.generateImageFilename();
|
||||||
const imageBuffer = await this.deps.generateImage(
|
const imageBuffer = await this.deps.generateImage(
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
subtitleMiningContext ?? undefined,
|
mediaTimingContext ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
@@ -309,7 +358,7 @@ export class NoteUpdateWorkflow {
|
|||||||
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
||||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||||
miscInfoFilename || '',
|
miscInfoFilename || '',
|
||||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
mediaTimingContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||||
);
|
);
|
||||||
const miscInfoField = this.deps.resolveConfiguredFieldName(
|
const miscInfoField = this.deps.resolveConfiguredFieldName(
|
||||||
noteInfo,
|
noteInfo,
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export class PendingYoutubeMediaQueue {
|
|||||||
generateAudio: shouldGenerateAudio(config),
|
generateAudio: shouldGenerateAudio(config),
|
||||||
generateImage: shouldGenerateImage(config),
|
generateImage: shouldGenerateImage(config),
|
||||||
volumeScale,
|
volumeScale,
|
||||||
|
...(job.context?.mediaPaddingSeconds !== undefined
|
||||||
|
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -275,7 +278,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
cachedMediaInput,
|
cachedMediaInput,
|
||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
config.media?.audioPadding,
|
job.mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
config.media?.normalizeAudio !== false,
|
config.media?.normalizeAudio !== false,
|
||||||
job.volumeScale,
|
job.volumeScale,
|
||||||
@@ -309,6 +312,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
|
job.mediaPaddingSeconds,
|
||||||
);
|
);
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||||
@@ -369,6 +373,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
|
mediaPaddingSeconds?: number,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
if (config.media?.imageType === 'avif') {
|
if (config.media?.imageType === 'avif') {
|
||||||
@@ -376,7 +381,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
config.media?.audioPadding,
|
mediaPaddingSeconds ?? config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: config.media?.animatedFps,
|
fps: config.media?.animatedFps,
|
||||||
maxWidth: config.media?.animatedMaxWidth,
|
maxWidth: config.media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
|
|||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
volumeScale?: number;
|
volumeScale?: number;
|
||||||
|
mediaPaddingSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
normalizeAudio: true,
|
normalizeAudio: true,
|
||||||
mirrorMpvVolume: true,
|
mirrorMpvVolume: true,
|
||||||
|
reviewTiming: false,
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
@@ -91,6 +92,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,
|
||||||
@@ -195,6 +196,13 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.media.reviewTiming',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
||||||
|
description:
|
||||||
|
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.media.generateImage',
|
path: 'ankiConnect.media.generateImage',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
@@ -374,6 +382,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/reviewTiming, 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.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,6 +21,34 @@ function makeContext(ankiConnect: unknown): {
|
|||||||
return { context, warnings };
|
return { context, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('media timing review is disabled by default and accepts a boolean override', () => {
|
||||||
|
const defaultContext = makeContext({});
|
||||||
|
applyAnkiConnectResolution(defaultContext.context);
|
||||||
|
assert.equal(defaultContext.context.resolved.ankiConnect.media.reviewTiming, false);
|
||||||
|
|
||||||
|
const enabledContext = makeContext({ media: { reviewTiming: true } });
|
||||||
|
applyAnkiConnectResolution(enabledContext.context);
|
||||||
|
assert.equal(enabledContext.context.resolved.ankiConnect.media.reviewTiming, true);
|
||||||
|
assert.deepEqual(enabledContext.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modern media duration accepts zero as the disabled cap sentinel', () => {
|
||||||
|
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
|
||||||
|
applyAnkiConnectResolution(disabledCap.context);
|
||||||
|
assert.equal(disabledCap.context.resolved.ankiConnect.media.maxMediaDuration, 0);
|
||||||
|
assert.deepEqual(disabledCap.warnings, []);
|
||||||
|
|
||||||
|
const invalidCap = makeContext({ media: { maxMediaDuration: -1 } });
|
||||||
|
applyAnkiConnectResolution(invalidCap.context);
|
||||||
|
assert.equal(
|
||||||
|
invalidCap.context.resolved.ankiConnect.media.maxMediaDuration,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidCap.warnings.some((warning) => warning.path === 'ankiConnect.media.maxMediaDuration'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
nPlusOne: { highlightEnabled: true },
|
nPlusOne: { highlightEnabled: true },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export function applyModernMediaResolution(
|
|||||||
'syncAnimatedImageToWordAudio',
|
'syncAnimatedImageToWordAudio',
|
||||||
'normalizeAudio',
|
'normalizeAudio',
|
||||||
'mirrorMpvVolume',
|
'mirrorMpvVolume',
|
||||||
|
'reviewTiming',
|
||||||
] as const) {
|
] as const) {
|
||||||
applyModernValue(
|
applyModernValue(
|
||||||
context,
|
context,
|
||||||
@@ -128,18 +129,28 @@ export function applyModernMediaResolution(
|
|||||||
'Expected non-negative number.',
|
'Expected non-negative number.',
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
applyModernValue(
|
||||||
applyModernValue(
|
context,
|
||||||
context,
|
media,
|
||||||
media,
|
'fallbackDuration',
|
||||||
key,
|
'ankiConnect.media.fallbackDuration',
|
||||||
`ankiConnect.media.${key}`,
|
asPositiveNumber,
|
||||||
asPositiveNumber,
|
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
|
||||||
DEFAULT_CONFIG.ankiConnect.media[key],
|
(value) => {
|
||||||
(value) => {
|
context.resolved.ankiConnect.media.fallbackDuration = value;
|
||||||
context.resolved.ankiConnect.media[key] = value;
|
},
|
||||||
},
|
'Expected positive number.',
|
||||||
'Expected positive number.',
|
);
|
||||||
);
|
applyModernValue(
|
||||||
}
|
context,
|
||||||
|
media,
|
||||||
|
'maxMediaDuration',
|
||||||
|
'ankiConnect.media.maxMediaDuration',
|
||||||
|
asNonNegativeNumber,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
||||||
|
(value) => {
|
||||||
|
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
||||||
|
},
|
||||||
|
'Expected non-negative number.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -243,6 +244,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
|
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,6 +257,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 +405,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.')) {
|
||||||
@@ -687,6 +695,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.ai.enabled' ||
|
path === 'ankiConnect.ai.enabled' ||
|
||||||
path === 'ankiConnect.media.normalizeAudio' ||
|
path === 'ankiConnect.media.normalizeAudio' ||
|
||||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||||
|
path === 'ankiConnect.media.reviewTiming' ||
|
||||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||||
@@ -702,6 +711,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' ||
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||||
|
|
||||||
|
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
||||||
|
type ImmersionTrackerServiceCtor =
|
||||||
|
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
||||||
|
|
||||||
|
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||||
|
|
||||||
|
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||||
|
if (trackerCtor) return trackerCtor;
|
||||||
|
const mod = await import('../immersion-tracker-service');
|
||||||
|
trackerCtor = mod.ImmersionTrackerService;
|
||||||
|
return trackerCtor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDbPath(): string {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||||
|
return path.join(dir, 'immersion.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupDbPath(dbPath: string): void {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackerInternals {
|
||||||
|
db: DatabaseSync;
|
||||||
|
queue: unknown[];
|
||||||
|
recordWrite: (write: Record<string, unknown>) => void;
|
||||||
|
deleteSession: (sessionId: number) => Promise<void>;
|
||||||
|
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
||||||
|
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
|
||||||
|
rebuildLifetimeSummaries: () => Promise<unknown>;
|
||||||
|
reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise<void>;
|
||||||
|
flushNow: () => void;
|
||||||
|
writeLock: { locked: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let deleteRunnerCalls = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath, policy: { batchSize: 2 } },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
deleteRunnerCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
let flushCalls = 0;
|
||||||
|
internals.flushNow = () => {
|
||||||
|
flushCalls += 1;
|
||||||
|
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||||
|
|
||||||
|
assert.equal(flushCalls, 1);
|
||||||
|
assert.equal(deleteRunnerCalls, 0);
|
||||||
|
assert.equal(internals.writeLock.locked, false);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
internals.flushNow = () => {};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||||
|
/queue did not drain/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
internals.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||||
|
)
|
||||||
|
.all(),
|
||||||
|
[
|
||||||
|
{ animeId: 1, anilistId: null },
|
||||||
|
{ animeId: 2, anilistId: 123 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
internals.flushNow = () => {};
|
||||||
|
|
||||||
|
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
internals.db
|
||||||
|
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
||||||
|
.all()
|
||||||
|
.map((row) => (row as { animeId: number }).animeId),
|
||||||
|
[1, 2],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
internals.flushNow = () => {};
|
||||||
|
|
||||||
|
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
internals.db
|
||||||
|
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
||||||
|
.get() as {
|
||||||
|
animeId: number;
|
||||||
|
}
|
||||||
|
).animeId,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
internals.flushNow = () => {};
|
||||||
|
|
||||||
|
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedTwoEntries(db: DatabaseSync): void {
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
||||||
|
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||||
|
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||||
|
INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||||
|
VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueSubtitleLines(tracker: TrackerInternals, count: number): void {
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
tracker.recordWrite({
|
||||||
|
kind: 'subtitleLine',
|
||||||
|
sessionId: 1,
|
||||||
|
videoId: 2,
|
||||||
|
lineIndex: index,
|
||||||
|
segmentStartMs: index * 1000,
|
||||||
|
segmentEndMs: index * 1000 + 900,
|
||||||
|
text: `line ${index}`,
|
||||||
|
wordOccurrences: [],
|
||||||
|
kanjiOccurrences: [],
|
||||||
|
firstSeen: 1000,
|
||||||
|
lastSeen: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queued last so it sits past the first batch. Lifetime `total_lines_seen`
|
||||||
|
* reads this counter, not a COUNT over imm_subtitle_lines, so the rebuilt
|
||||||
|
* summary only reflects the session once the queue is drained all the way.
|
||||||
|
*/
|
||||||
|
function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||||
|
tracker.recordWrite({
|
||||||
|
kind: 'telemetry',
|
||||||
|
sessionId: 1,
|
||||||
|
sampleMs: 3000,
|
||||||
|
lastMediaMs: 3000,
|
||||||
|
totalWatchedMs: 4000,
|
||||||
|
activeWatchedMs: 3500,
|
||||||
|
linesSeen,
|
||||||
|
tokensSeen: linesSeen * 5,
|
||||||
|
cardsMined: 2,
|
||||||
|
lookupCount: 0,
|
||||||
|
lookupHits: 0,
|
||||||
|
yomitanLookupCount: 0,
|
||||||
|
pauseCount: 0,
|
||||||
|
pauseMs: 0,
|
||||||
|
seekForwardCount: 0,
|
||||||
|
seekBackwardCount: 0,
|
||||||
|
mediaBufferEvents: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||||
|
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT lines_seen AS linesSeen
|
||||||
|
FROM imm_session_telemetry
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get(sessionId) as { linesSeen: number } | undefined;
|
||||||
|
return row ? Number(row.linesSeen) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||||
|
.get(animeId) as { total: number };
|
||||||
|
return Number(row.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both entry points must see a settled database before changing episode
|
||||||
|
* ownership. A single flushNow() only writes one batch off the front of the
|
||||||
|
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||||
|
* repoints rows.
|
||||||
|
*/
|
||||||
|
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 8);
|
||||||
|
queueTelemetry(internals, 8);
|
||||||
|
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
||||||
|
|
||||||
|
await internals.mergeAnime(1, [2]);
|
||||||
|
|
||||||
|
assert.equal(internals.queue.length, 0);
|
||||||
|
// Every queued line landed, attributed to the surviving entry.
|
||||||
|
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||||
|
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 8);
|
||||||
|
queueTelemetry(internals, 8);
|
||||||
|
|
||||||
|
await internals.moveVideoToAnime(2, 1);
|
||||||
|
|
||||||
|
assert.equal(internals.queue.length, 0);
|
||||||
|
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||||
|
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
|
||||||
@@ -1053,6 +1227,55 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
|
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/merge-recommendations');
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(await res.json(), {
|
||||||
|
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
|
||||||
|
let dismissedId: number | null = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
|
||||||
|
dismissedId = recommendationId;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(dismissedId, 4);
|
||||||
|
assert.deepEqual(await res.json(), { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
dismissAnimeMergeRecommendation: async () => false,
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
|
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1');
|
const res = await app.request('/api/stats/anime/1');
|
||||||
@@ -2454,6 +2677,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');
|
||||||
@@ -2950,6 +3247,148 @@ Aligned English subtitle
|
|||||||
assert.equal(deleteCalls, 0);
|
assert.equal(deleteCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
|
||||||
|
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||||
|
merged = { targetAnimeId, sourceAnimeIds };
|
||||||
|
return {
|
||||||
|
survivingAnimeId: targetAnimeId,
|
||||||
|
mergedAnimeIds: sourceAnimeIds,
|
||||||
|
movedVideos: 3,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/7/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
// The target repeated in the sources must not delete the entry we keep.
|
||||||
|
body: '{"sourceAnimeIds":[8,9,8,7]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
|
||||||
|
assert.deepEqual(await res.json(), {
|
||||||
|
ok: true,
|
||||||
|
animeId: 7,
|
||||||
|
mergedAnimeIds: [8, 9],
|
||||||
|
movedVideos: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
|
||||||
|
let mergeCalls = 0;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
mergeAnime: async () => {
|
||||||
|
mergeCalls += 1;
|
||||||
|
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const body of [
|
||||||
|
'{"sourceAnimeIds":[]}',
|
||||||
|
'{"sourceAnimeIds":[7]}',
|
||||||
|
'{"sourceAnimeIds":0}',
|
||||||
|
]) {
|
||||||
|
const res = await app.request('/api/stats/anime/7/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
}
|
||||||
|
assert.equal(mergeCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
|
||||||
|
let moved: { videoId: number; animeId: number } | null = null;
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
moveVideoToAnime: async (videoId: number, animeId: number) => {
|
||||||
|
moved = { videoId, animeId };
|
||||||
|
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/media/12/anime', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"animeId":7}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
|
||||||
|
assert.deepEqual(await res.json(), {
|
||||||
|
ok: true,
|
||||||
|
animeId: 7,
|
||||||
|
previousAnimeId: 4,
|
||||||
|
removedPreviousAnime: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
mergeAnime: async (targetAnimeId: number) => ({
|
||||||
|
survivingAnimeId: targetAnimeId,
|
||||||
|
mergedAnimeIds: [],
|
||||||
|
movedVideos: 0,
|
||||||
|
}),
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/anime/7/merge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"sourceAnimeIds":[8]}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
moveVideoToAnime: async () => {
|
||||||
|
throw new Error('Unknown episode or target library entry');
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/media/12/anime', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"animeId":99}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
|
||||||
|
const app = createStatsApp(
|
||||||
|
createMockTracker({
|
||||||
|
moveVideoToAnime: async () => {
|
||||||
|
throw new Error('database is locked');
|
||||||
|
},
|
||||||
|
} as Partial<ImmersionTrackerService>),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/media/12/anime', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"animeId":7}',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.notEqual(res.status, 404);
|
||||||
|
assert.equal(res.status >= 500, true);
|
||||||
|
});
|
||||||
|
|
||||||
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' });
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ export function createCoverArtFetcher(
|
|||||||
titleEnglish: selected.title?.english ?? null,
|
titleEnglish: selected.title?.english ?? null,
|
||||||
titleNative: selected.title?.native ?? null,
|
titleNative: selected.title?.native ?? null,
|
||||||
episodesTotal: selected.episodes ?? null,
|
episodesTotal: selected.episodes ?? null,
|
||||||
|
exactTitleMatch: resolution?.exactTitleMatch ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -156,6 +156,79 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
|
|||||||
assert.deepEqual(relationLookups, []);
|
assert.deepEqual(relationLookups, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a sequel resolution is not certified by the anchor exact-title evidence', async () => {
|
||||||
|
// The anchor matched the search title exactly, but the hopped-to entry is a
|
||||||
|
// different inference (a split-cour chain can land one season short), so the
|
||||||
|
// sequel result must report its own title evidence, not the anchor's.
|
||||||
|
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||||
|
const result = await resolveAnilistSeasonMedia(
|
||||||
|
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
|
||||||
|
{ execute },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.id, 20698);
|
||||||
|
assert.equal(result?.via, 'sequel-chain');
|
||||||
|
assert.equal(result?.exactTitleMatch, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sequel resolution whose own title matches the parsed title stays exact', async () => {
|
||||||
|
const anchor: AnilistSeasonMedia = {
|
||||||
|
id: 1,
|
||||||
|
episodes: 12,
|
||||||
|
format: 'TV',
|
||||||
|
title: { english: 'Show' },
|
||||||
|
};
|
||||||
|
const sequel: AnilistSeasonMedia = {
|
||||||
|
id: 2,
|
||||||
|
episodes: 12,
|
||||||
|
format: 'TV',
|
||||||
|
title: { english: 'Show 2nd Season' },
|
||||||
|
};
|
||||||
|
const { execute } = createExecutor([anchor], {
|
||||||
|
1: [{ relationType: 'SEQUEL', node: sequel }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveAnilistSeasonMedia(
|
||||||
|
{ title: 'Show 2nd Season', season: 2, episode: 1 },
|
||||||
|
{ execute },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result?.id, 2);
|
||||||
|
assert.equal(result?.via, 'sequel-chain');
|
||||||
|
assert.equal(result?.exactTitleMatch, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports an exact normalized synonym match as strong evidence', async () => {
|
||||||
|
const { execute } = createExecutor([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
episodes: 12,
|
||||||
|
format: 'TV',
|
||||||
|
title: { english: 'Hitori Gotoh Story' },
|
||||||
|
synonyms: ['BOCCHI THE ROCK'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await resolveAnilistSeasonMedia({ title: 'Bocchi the Rock!' }, { execute });
|
||||||
|
|
||||||
|
assert.equal(result?.exactTitleMatch, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a fuzzy-only search result as weak evidence', async () => {
|
||||||
|
const { execute } = createExecutor([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
episodes: 12,
|
||||||
|
format: 'TV',
|
||||||
|
title: { english: 'Actual Show' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await resolveAnilistSeasonMedia({ title: 'Unrelated Release' }, { execute });
|
||||||
|
|
||||||
|
assert.equal(result?.exactTitleMatch, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('strips a season marker already present in the parsed title', async () => {
|
test('strips a season marker already present in the parsed title', async () => {
|
||||||
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||||
const result = await resolveAnilistSeasonMedia(
|
const result = await resolveAnilistSeasonMedia(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||||
|
|
||||||
export interface AnilistSeasonMediaTitle {
|
export interface AnilistSeasonMediaTitle {
|
||||||
romaji?: string | null;
|
romaji?: string | null;
|
||||||
english?: string | null;
|
english?: string | null;
|
||||||
@@ -42,6 +44,8 @@ export interface AnilistSeasonResolution {
|
|||||||
seasonResolved: boolean;
|
seasonResolved: boolean;
|
||||||
requestedSeason: number | null;
|
requestedSeason: number | null;
|
||||||
via: AnilistSeasonResolutionVia;
|
via: AnilistSeasonResolutionVia;
|
||||||
|
/** Exact normalized match against an AniList title or synonym. */
|
||||||
|
exactTitleMatch: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolveAnilistSeasonMediaInput {
|
export interface ResolveAnilistSeasonMediaInput {
|
||||||
@@ -115,10 +119,6 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
|||||||
|
|
||||||
const MAX_SEQUEL_HOPS = 12;
|
const MAX_SEQUEL_HOPS = 12;
|
||||||
|
|
||||||
function normalizeTitle(value: string): string {
|
|
||||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops season markers a release name carries but AniList titles never do,
|
* Drops season markers a release name carries but AniList titles never do,
|
||||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
||||||
@@ -136,7 +136,7 @@ function mediaTitles(media: AnilistSeasonMedia): string[] {
|
|||||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
.map((value) => normalizeTitle(value));
|
.map((value) => normalizeTitleIdentity(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
||||||
@@ -176,6 +176,7 @@ function toResolution(
|
|||||||
season: number | null,
|
season: number | null,
|
||||||
via: AnilistSeasonResolutionVia,
|
via: AnilistSeasonResolutionVia,
|
||||||
seasonResolved: boolean,
|
seasonResolved: boolean,
|
||||||
|
exactTitleMatch: boolean,
|
||||||
): AnilistSeasonResolution {
|
): AnilistSeasonResolution {
|
||||||
return {
|
return {
|
||||||
id: media.id,
|
id: media.id,
|
||||||
@@ -185,6 +186,7 @@ function toResolution(
|
|||||||
seasonResolved,
|
seasonResolved,
|
||||||
requestedSeason: season,
|
requestedSeason: season,
|
||||||
via,
|
via,
|
||||||
|
exactTitleMatch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,9 +211,10 @@ export function pickAnchorMedia(
|
|||||||
: media;
|
: media;
|
||||||
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||||
|
|
||||||
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
|
const targets = [
|
||||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
normalizeTitleIdentity(title),
|
||||||
);
|
normalizeTitleIdentity(stripSeasonSuffix(title)),
|
||||||
|
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||||
|
|
||||||
const scored = pool.map((entry, index) => {
|
const scored = pool.map((entry, index) => {
|
||||||
const candidateTitles = mediaTitles(entry);
|
const candidateTitles = mediaTitles(entry);
|
||||||
@@ -367,9 +370,20 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
episode: season === null || season <= 1 ? input.episode : null,
|
episode: season === null || season <= 1 ? input.episode : null,
|
||||||
});
|
});
|
||||||
if (!anchor) return null;
|
if (!anchor) return null;
|
||||||
|
// Certifies the media actually returned, never the anchor on its behalf: a
|
||||||
|
// sequel-chain hop can land one season short (split-cour entries) while the
|
||||||
|
// anchor title still matches perfectly, and that certainty must not carry
|
||||||
|
// over to the hopped-to entry.
|
||||||
|
const exactMatchFor = (candidate: AnilistSeasonMedia): boolean => {
|
||||||
|
const titles = mediaTitles(candidate);
|
||||||
|
return (
|
||||||
|
titles.includes(normalizeTitleIdentity(searchTitle)) ||
|
||||||
|
titles.includes(normalizeTitleIdentity(input.title))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (season === null || season <= 1) {
|
if (season === null || season <= 1) {
|
||||||
return toResolution(anchor, searchTitle, season, 'anchor', true);
|
return toResolution(anchor, searchTitle, season, 'anchor', true, exactMatchFor(anchor));
|
||||||
}
|
}
|
||||||
|
|
||||||
let chainError: unknown = null;
|
let chainError: unknown = null;
|
||||||
@@ -383,7 +397,14 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||||
);
|
);
|
||||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
return toResolution(
|
||||||
|
viaChain,
|
||||||
|
searchTitle,
|
||||||
|
season,
|
||||||
|
'sequel-chain',
|
||||||
|
true,
|
||||||
|
exactMatchFor(viaChain),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||||
@@ -391,7 +412,14 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
||||||
);
|
);
|
||||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
return toResolution(
|
||||||
|
viaAirOrder,
|
||||||
|
searchTitle,
|
||||||
|
season,
|
||||||
|
'air-order',
|
||||||
|
true,
|
||||||
|
exactMatchFor(viaAirOrder),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chain failed for transport reasons rather than because the season is absent;
|
// The chain failed for transport reasons rather than because the season is absent;
|
||||||
@@ -403,5 +431,5 @@ export async function resolveAnilistSeasonMedia(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||||
);
|
);
|
||||||
return toResolution(anchor, searchTitle, season, 'anchor', false);
|
return toResolution(anchor, searchTitle, season, 'anchor', false, exactMatchFor(anchor));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
assOverrideSignature,
|
||||||
|
assToPlainText,
|
||||||
|
collectAssOverrideCommands,
|
||||||
|
extractAssOverrideBlocks,
|
||||||
|
hasAssTemporalOverride,
|
||||||
|
isAnimatedAssEffectKind,
|
||||||
|
isAssTemporalCommand,
|
||||||
|
normalizePlainSubtitleText,
|
||||||
|
parseAssEffectField,
|
||||||
|
} from './ass-text';
|
||||||
|
|
||||||
|
test('assToPlainText drops vector drawing runs', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText(
|
||||||
|
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||||
|
assert.equal(
|
||||||
|
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||||
|
'本文続き',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||||
|
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||||
|
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText resolves line-break and space escapes', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||||
|
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||||
|
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||||
|
// braces still open and close an override block.
|
||||||
|
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||||
|
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||||
|
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||||
|
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText is idempotent', () => {
|
||||||
|
const samples = [
|
||||||
|
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||||
|
'\\{注\\}',
|
||||||
|
'\\\\N',
|
||||||
|
'本文{\\pos(1,2)',
|
||||||
|
'一行目\\N二行目\\h終わり',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const once = assToPlainText(sample);
|
||||||
|
assert.equal(assToPlainText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assToPlainText normalizes CRLF before converting', () => {
|
||||||
|
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||||
|
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||||
|
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||||
|
assert.equal(
|
||||||
|
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||||
|
'一行目 二行目',
|
||||||
|
);
|
||||||
|
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePlainSubtitleText is idempotent', () => {
|
||||||
|
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||||
|
const once = normalizePlainSubtitleText(sample);
|
||||||
|
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractAssOverrideBlocks returns block contents', () => {
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||||
|
'\\an8',
|
||||||
|
'\\fad(200,200)',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
{ name: 'pos', args: '1,2', animated: false },
|
||||||
|
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||||
|
{ name: 'kf', args: '30', animated: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||||
|
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||||
|
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
commands.map((command) => [command.name, command.animated]),
|
||||||
|
[
|
||||||
|
['clip', false],
|
||||||
|
['t', false],
|
||||||
|
['frz', true],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
||||||
|
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
||||||
|
// pathological line (real files reach one or two levels) overflows the stack.
|
||||||
|
const nesting = 32;
|
||||||
|
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
||||||
|
|
||||||
|
const commands = collectAssOverrideCommands(block);
|
||||||
|
|
||||||
|
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
||||||
|
assert.equal(commands.length, 9);
|
||||||
|
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
||||||
|
assert.equal(hasAssTemporalOverride(commands), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||||
|
assert.equal(
|
||||||
|
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||||
|
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), true, command);
|
||||||
|
}
|
||||||
|
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||||
|
assert.equal(isAssTemporalCommand(command), false, command);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||||
|
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||||
|
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||||
|
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||||
|
|
||||||
|
assert.notEqual(first, second);
|
||||||
|
assert.equal(first, repeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||||
|
assert.equal(parseAssEffectField(''), 'none');
|
||||||
|
assert.equal(parseAssEffectField(' '), 'none');
|
||||||
|
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||||
|
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||||
|
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||||
|
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||||
|
// Custom effect names that merely start with a stock name are not stock effects.
|
||||||
|
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||||
|
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||||
|
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||||
|
// Typesetting groups put static template names in this column too.
|
||||||
|
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||||
|
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
/*
|
||||||
|
* ASS/SSA text handling, split into two deliberately distinct contracts:
|
||||||
|
*
|
||||||
|
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
|
||||||
|
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
|
||||||
|
*
|
||||||
|
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
|
||||||
|
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
|
||||||
|
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
|
||||||
|
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
|
||||||
|
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
|
||||||
|
*
|
||||||
|
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
|
||||||
|
* so a cue parsed from a file reads the same as the same line arriving live:
|
||||||
|
* - `{...}` override blocks are markup
|
||||||
|
* - `\pN ... \p0` runs are vector paths, not dialogue
|
||||||
|
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
|
||||||
|
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
|
||||||
|
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
|
||||||
|
* Because the decoder never emits an escape or a closed brace, running it twice is a
|
||||||
|
* no-op -- but downstream code should still use normalizePlainSubtitleText.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What `\N` and `\n` become. */
|
||||||
|
export type AssLineBreak = '\n' | ' ';
|
||||||
|
|
||||||
|
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
|
||||||
|
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
|
||||||
|
// `\pos(...)` from being read as a drawing tag.
|
||||||
|
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
|
||||||
|
|
||||||
|
function readDrawingScale(block: string): number | null {
|
||||||
|
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
|
||||||
|
let scale: number | null = null;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
// Drawing mode is whatever the last `\p` tag in this block set it to.
|
||||||
|
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
|
||||||
|
scale = match[1] ? Number(match[1]) : 0;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
|
||||||
|
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
|
||||||
|
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
|
||||||
|
escaped === 'h' ? ' ' : lineBreak,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip `{...}` override blocks and the drawing runs they enable. */
|
||||||
|
function stripAssMarkup(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
let cursor = 0;
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
while (cursor < raw.length) {
|
||||||
|
if (raw[cursor] !== '{') {
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw[cursor];
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = raw.indexOf('}', cursor + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
|
||||||
|
// meant to end can eat a whole line of dialogue.
|
||||||
|
if (!drawing) {
|
||||||
|
out += raw.slice(cursor);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = readDrawingScale(raw.slice(cursor, close + 1));
|
||||||
|
if (scale !== null) {
|
||||||
|
drawing = scale > 0;
|
||||||
|
}
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
|
||||||
|
* downstream layers take the result as plain text.
|
||||||
|
*/
|
||||||
|
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizePlainSubtitleTextOptions {
|
||||||
|
/** Fold every line break into a single space. */
|
||||||
|
collapseLineBreaks?: boolean;
|
||||||
|
trim?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whitespace normalization for text that has already been decoded -- by mpv for live
|
||||||
|
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
|
||||||
|
* this function's business; a `{` that reaches here is literal text mpv chose to show.
|
||||||
|
*
|
||||||
|
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
|
||||||
|
* and other websocket clients) forward them raw and the display layer has to cope.
|
||||||
|
*/
|
||||||
|
export function normalizePlainSubtitleText(
|
||||||
|
text: string,
|
||||||
|
options: NormalizePlainSubtitleTextOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const { collapseLineBreaks = false, trim = true } = options;
|
||||||
|
|
||||||
|
let normalized = resolveWhitespaceEscapes(
|
||||||
|
text.replace(/\r\n/g, '\n'),
|
||||||
|
collapseLineBreaks ? ' ' : '\n',
|
||||||
|
);
|
||||||
|
if (collapseLineBreaks) {
|
||||||
|
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim ? normalized.trim() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The contents of each `{...}` block, without the braces. */
|
||||||
|
export function extractAssOverrideBlocks(text: string): string[] {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < text.length) {
|
||||||
|
const open = text.indexOf('{', cursor);
|
||||||
|
if (open === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const close = text.indexOf('}', open + 1);
|
||||||
|
if (close === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
blocks.push(text.slice(open + 1, close));
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssOverrideCommand {
|
||||||
|
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
|
||||||
|
name: string;
|
||||||
|
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
|
||||||
|
args: string;
|
||||||
|
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
|
||||||
|
animated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
|
||||||
|
|
||||||
|
function readCommandArgs(block: string, start: number): { args: string; next: number } {
|
||||||
|
if (block[start] === '(') {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = start; i < block.length; i += 1) {
|
||||||
|
if (block[i] === '(') depth += 1;
|
||||||
|
else if (block[i] === ')') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return { args: block.slice(start + 1, i), next: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { args: block.slice(start + 1), next: block.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTag = block.indexOf('\\', start);
|
||||||
|
const end = nextTag === -1 ? block.length : nextTag;
|
||||||
|
return { args: block.slice(start, end), next: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
|
||||||
|
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
|
||||||
|
// or two levels, so stop recursing well before the call stack is at risk.
|
||||||
|
const MAX_ANIMATION_NESTING_DEPTH = 8;
|
||||||
|
|
||||||
|
function parseOverrideBlock(
|
||||||
|
block: string,
|
||||||
|
animated: boolean,
|
||||||
|
into: AssOverrideCommand[],
|
||||||
|
depth = 0,
|
||||||
|
): void {
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < block.length) {
|
||||||
|
if (block[cursor] !== '\\') {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
|
||||||
|
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
|
||||||
|
if (!nameMatch) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = nameMatch[0];
|
||||||
|
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||||
|
into.push({ name, args: args.trim(), animated });
|
||||||
|
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||||
|
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||||
|
parseOverrideBlock(args, true, into, depth + 1);
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override commands with their arguments, in source order. Only `{...}` blocks are
|
||||||
|
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
|
||||||
|
*/
|
||||||
|
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
|
||||||
|
const commands: AssOverrideCommand[] = [];
|
||||||
|
for (const block of extractAssOverrideBlocks(text)) {
|
||||||
|
parseOverrideBlock(block, false, commands);
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
|
||||||
|
// karaoke tags advance a highlight across the event's own duration. Everything else --
|
||||||
|
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
|
||||||
|
// presence says nothing about whether neighbouring events form one animation.
|
||||||
|
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
|
||||||
|
|
||||||
|
export function isAssTemporalCommand(name: string): boolean {
|
||||||
|
return ASS_TEMPORAL_COMMANDS.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
|
||||||
|
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
|
||||||
|
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical form of an event's override values, for comparing consecutive events. Two
|
||||||
|
* events with the same signature were typeset identically, so neither is a frame of an
|
||||||
|
* animation the other belongs to.
|
||||||
|
*/
|
||||||
|
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
|
||||||
|
return commands.map((command) => `${command.name}(${command.args})`).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
|
||||||
|
|
||||||
|
// The stock effects, matched exactly. Typesetting groups put their own template names in
|
||||||
|
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
|
||||||
|
// prefix match would hand out animation evidence to arbitrary custom effects.
|
||||||
|
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
|
||||||
|
['banner', 'banner'],
|
||||||
|
['scroll up', 'scroll'],
|
||||||
|
['scroll down', 'scroll'],
|
||||||
|
['karaoke', 'karaoke'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
|
||||||
|
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
|
||||||
|
* `other`.
|
||||||
|
*/
|
||||||
|
export function parseAssEffectField(raw: string): AssEffectKind {
|
||||||
|
const value = raw.trim().toLowerCase();
|
||||||
|
if (!value) return 'none';
|
||||||
|
|
||||||
|
const name = value.split(';', 1)[0]!.trim();
|
||||||
|
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
|
||||||
|
|
||||||
|
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
|
||||||
|
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user