Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
925413adfe
|
|||
| d0644ab2eb | |||
| d253710c2e | |||
|
c3df510e4f
|
|||
| 187f68e5b6 | |||
|
0e254cbbef
|
|||
|
7b94adafbd
|
|||
| 61f39d1e09 | |||
| e7739de51c | |||
| ad1d240f20 | |||
|
8b0ef662bc
|
|||
| 0a58c20ad7 | |||
| 38ddb29aa0 | |||
| 8b9a70c5a6 | |||
| 48a084914a | |||
| a042b04357 |
@@ -55,9 +55,6 @@ jobs:
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Internal docs knowledge-base checks
|
||||
run: bun run test:docs:kb
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
@@ -71,6 +68,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ Start here, then leave this file.
|
||||
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 436 B |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 549 B |
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character-dictionary surname/given-name splitting for unspaced AniList native names where romanized reading length misled the boundary guess (東紫乃 split as 東紫+乃, 渡辺真奈美 as 渡辺真+奈美), so surnames like 東 and 渡辺 were never highlighted with character portraits and unmatched leading kanji (渡) lost hover lookup entirely. Name parts are now resolved with MeCab person-name POS tags and reading validation when MeCab is available; without MeCab, terms are generated for both plausible boundaries so the real surname still matches. Existing snapshots regenerate automatically (format version bump), and snapshots built without MeCab are upgraded to exact MeCab splits once MeCab is detected.
|
||||
- Fixed subtitle tokenization dropping a character entirely when Yomitan's greedy scan consumed trailing punctuation/whitespace past the term (e.g. 平 in 「(東)平 (平)え?」 matched across the paren with no exact-source headword). The scanner now retries shorter windows so the prefix term still tokenizes with its name match and portrait.
|
||||
- Fixed character-name highlights being stripped when MeCab's context-dependent POS tag put the token in the annotation noise filter (e.g. 平 tagged as prefix 接頭詞 in 「あっ 平 これ…」 lost its highlight and portrait even though hover lookup showed the character). Confirmed name matches now survive the POS exclusion filter, and name matching alone keeps the annotation stage active when all other annotations are disabled.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Cover art is now fetched eagerly when a new series starts playing, instead of waiting for the first visit to its series detail page, so the stats timeline shows the best-guess AniList image right away. The stats covers endpoint also backfills missing series art in the background, so existing series without an image pick one up on the next stats page load.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: launcher
|
||||
|
||||
- Moved fzf previews below launcher menus so long titles and metadata have more horizontal room.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed never-mined compound words (e.g. 待ち合わせてる) being highlighted green as known: subtitle tokens now carry complete readings instead of kanji-only furigana joins, and the known-word reading fallback rejects readings that don't cover the token surface. Stored word readings in the stats database are no longer truncated for new lines.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed single-kana grammar tokens counting as known words by borrowing the reading of an unrelated Anki note (よ in 全然いいよ matched a card read よ such as 夜, standalone え matched 絵), which painted them with the known-word highlight. Reading-only known-word matching now requires at least two kana; single-kana cards still match by their word field, and tokens genuinely present in the known-words cache (e.g. です) keep their highlight.
|
||||
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: branding
|
||||
|
||||
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Session known-word counts no longer show 0 everywhere. The stats server's known-word cache parser only understood the v1/v2 cache formats, so after the reading-aware v3 cache upgrade it silently treated the cache as missing; it now flattens v3 note entries into the headword set.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
|
||||
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
|
||||
- The change-verification skill gained a `stats` lane for `stats/` edits.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed subtitle text that Yomitan's parser cannot match (e.g. the truncated volitional in とこ戻ろ…) being rendered as plain, non-interactive text: it was invisible to hover/lookup and excluded from the n+1 word count, which could wrongly mark a sentence as n+1. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation; bracketed SFX/speaker captions and punctuation-only runs are still skipped.
|
||||
@@ -569,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
|
||||
@@ -56,6 +56,8 @@ A single character produces many searchable terms so that names are recognized r
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
|
||||
- ア・リ・ス → アリス (combined), plus individual segments
|
||||
|
||||
@@ -80,18 +80,24 @@ Default lanes:
|
||||
|
||||
```bash
|
||||
bun run test # alias for test:fast
|
||||
bun run test:fast # default fast lane
|
||||
bun run test:full # maintained source + launcher-unit + runtime compat surface
|
||||
bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
|
||||
bun run test:runtime:compat # compiled/runtime compatibility slice only
|
||||
bun run test:env # launcher/plugin + env-sensitive verification
|
||||
bun run test:stats # stats dashboard UI suite
|
||||
bun run test:immersion:sqlite # SQLite persistence lane
|
||||
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
|
||||
```
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks.
|
||||
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`.
|
||||
Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
|
||||
runs each test file in its own `bun test` process (per-file isolation) so a hanging
|
||||
test or leaked global in one file cannot cascade into the rest of the lane; pass
|
||||
`--jobs N` to parallelize or `--single-process` for one shared process.
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
|
||||
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
|
||||
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
|
||||
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
|
||||
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
|
||||
|
||||
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
|
||||
@@ -126,11 +132,11 @@ Focused commands:
|
||||
```bash
|
||||
bun run test:config # Source-level config schema/validation tests
|
||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
||||
bun run test:core # Source-level core regression tests (default lane)
|
||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
||||
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||
bun run test:scripts # Bun-managed scripts/** test lane
|
||||
bun run test:immersion:sqlite:src # Bun source lane
|
||||
```
|
||||
|
||||
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
|
||||
bun run build # compile dist artifacts
|
||||
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
|
||||
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
||||
bun run test:config:dist # optional full dist config suite
|
||||
bun run test:core:dist # optional full dist core suite
|
||||
```
|
||||
|
||||
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
|
||||
|
||||
@@ -95,7 +95,7 @@ Stats server config lives under `stats`:
|
||||
|
||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
@@ -569,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -3,10 +3,22 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-07-06
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
## Lane Infrastructure
|
||||
|
||||
- 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
|
||||
files in `package.json`.
|
||||
- `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
|
||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||
`--single-process` restores the shared-process mode for debugging.
|
||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||
unit, `scripts/**`, and the compiled runtime-compat slice.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
```bash
|
||||
@@ -31,6 +43,8 @@ bun run docs:build
|
||||
- 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`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
APPDATA: xdgConfigHome,
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
|
||||
// resolves managed asset paths deterministically (not the CI runner's).
|
||||
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
|
||||
PATH: pathValue,
|
||||
Path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||
// when the runtime plugin/theme are missing — spawns the app with
|
||||
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||
// never writes that response, so the launcher hangs and the test times out on
|
||||
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||
// the fake app install the managed plugin/theme and write the response, matching
|
||||
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||
mkdir -p "$(dirname "$3")"
|
||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
`;
|
||||
|
||||
test('config path uses XDG_CONFIG_HOME override', () => {
|
||||
withTempDir((root) => {
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
);
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -566,17 +591,22 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
||||
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||
|
||||
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
|
||||
// *every* app command — including the Linux runtime-plugin preflight's
|
||||
// `--ensure-linux-runtime-plugin-assets` install — and returns without
|
||||
// running the fake app, so the preflight would poll 30s for a response that
|
||||
// never arrives and time out. This test asserts on the mpv args, not on
|
||||
// captured app args, so capture isn't needed.
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
DISPLAY: ':99',
|
||||
XDG_SESSION_TYPE: 'x11',
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
|
||||
};
|
||||
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
|
||||
// Pass an explicit backend so overlay startup doesn't probe for a display
|
||||
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
|
||||
const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
const forwardedArgs = fs
|
||||
@@ -597,7 +627,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -626,7 +656,7 @@ test('dictionary command forwards manual AniList selection modes to app command
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -763,7 +793,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -790,7 +820,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -817,7 +847,7 @@ test('jellyfin login routes credentials to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -856,7 +886,7 @@ test('jellyfin setup forwards password-store to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function showFzfFlatMenu(
|
||||
`--prompt=${prompt}`,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCommand,
|
||||
];
|
||||
@@ -468,7 +468,7 @@ thumb=$(get_thumb)
|
||||
'--prompt=Select Video: ',
|
||||
'--delimiter=\t',
|
||||
'--with-nth=1',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCmd,
|
||||
],
|
||||
|
||||
@@ -105,6 +105,8 @@ bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verif
|
||||
- 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`
|
||||
|
||||
@@ -108,6 +108,14 @@ for path in "${PATHS[@]}"; do
|
||||
;;
|
||||
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"
|
||||
|
||||
@@ -255,8 +255,18 @@ write_summary_files() {
|
||||
local lane_lines
|
||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.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" \
|
||||
@@ -482,6 +492,9 @@ for lane in "${SELECTED_LANES[@]}"; do
|
||||
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
|
||||
|
||||
@@ -1,24 +1,65 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.17.1 -->
|
||||
<!-- prerelease-base-version: 0.18.0 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
- **Watch History Browser**
|
||||
- New `subminer -H` / `--history` command lets you browse your local watch history, replay the last episode, jump to the next one, or pick an episode via fzf or rofi.
|
||||
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
|
||||
- **Card Audio Normalization**
|
||||
- Audio extracted for Anki cards is now volume-normalized by default, giving more consistent playback loudness across cards.
|
||||
- Prefer the original source volume? Disable it via the new `ankiConnect.media.normalizeAudio` setting.
|
||||
|
||||
- **YouTube Media Cache Mode**: A new `youtube.mediaCache.mode` setting (`direct` or `background`) lets you choose how SubMiner extracts audio and image from YouTube cards.
|
||||
- In background mode, SubMiner creates a text-only card immediately, downloads a yt-dlp media cache (capped at 720p by default), and fills audio and image fields once the file is ready — with overlay and OSD notifications when the download starts and when media is available.
|
||||
- If a background download fails, SubMiner now notifies you and clears any pending media updates rather than leaving cards silently incomplete.
|
||||
### Changed
|
||||
- **New App Icon**
|
||||
- SubMiner now ships pixel-art submarine artwork contributed by an anonymous community member.
|
||||
- Applied across the app icon, tray icon, notifications, README, docs site, and stats page.
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews in the launcher now sit below the menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Log Export**: Log filenames now use your local date, so exporting logs near midnight no longer pulls stale files from the previous UTC day. Export redaction has also been expanded to mask a broader range of sensitive data, including IP addresses, email addresses, authentication and cookie headers, yt-dlp cookie arguments, URL credentials, and signed YouTube media URLs.
|
||||
|
||||
- **YouTube Card Media Reliability**: Direct stream extraction now uses safer ffmpeg options and skips stale or cached stream map entries to reduce failed media generation. Background cache downloads are hardened with IPv4 and extractor retry flags, stale cache files are cleaned up on startup and before new downloads, and in-flight background downloads are stopped automatically when switching back to direct mode.
|
||||
- **Character Name Highlighting in Subtitles**
|
||||
- Fixed unspaced Japanese names (e.g. 東紫乃, 渡辺真奈美) being split at the wrong point, which left surnames like 東 and 渡辺 without their character portrait or hover lookup.
|
||||
- Fixed names getting cut off or losing their highlight when caught by the subtitle scanner's punctuation handling, or stripped entirely when grammar tagging misclassified the name token.
|
||||
- No action needed — existing data upgrades automatically the next time a matching name is seen.
|
||||
- **Known-Word Highlighting**
|
||||
- Words are no longer marked "known" (green) just because they share spelling with a known Anki card that actually teaches a different reading (e.g. 床 read as とこ no longer falsely matches a known 床/ゆか card).
|
||||
- Single-kana grammar tokens (particles like よ, え) no longer borrow an unrelated card's reading and get falsely painted as known.
|
||||
- Stats sessions now correctly reflect known-word counts again after the reading-aware matching upgrade, instead of showing 0 everywhere.
|
||||
- **Unparsed Subtitle Text**
|
||||
- Subtitle text the dictionary can't recognize (like a truncated verb form) is now still hoverable for lookup and correctly counted toward a sentence's difficulty, instead of showing as dead, non-interactive text.
|
||||
- **Kiku Manual Field Grouping**
|
||||
- Fixed the field-grouping dialog getting stuck invisible behind fullscreen video on Hyprland/Wayland, and failing silently on repeated attempts after the first use.
|
||||
- Fixed a duplicate "Field grouping cancelled" notification appearing when grouping was cancelled via the trigger shortcut.
|
||||
- **Secondary Subtitles**
|
||||
- Karaoke-style secondary subtitles (common in opening/ending songs) no longer spam dozens of lines down the screen; repeated lines are now collapsed and the subtitle area is capped to a strip at the top.
|
||||
- **YouTube Extraction**
|
||||
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
|
||||
- **Background Stats Server**
|
||||
- Launching SubMiner in the background now correctly auto-starts the stats server when enabled, and won't start a duplicate if one's already running.
|
||||
- **Stats Trend Charts**
|
||||
- All trend chart titles now show by default, with the ability to hide specific titles (remembered across sessions) and cap how many top titles a chart displays.
|
||||
- **Stats Cover Art**
|
||||
- Cover art now loads as soon as a series starts playing instead of waiting for your first visit to its detail page, so the stats timeline shows artwork right away.
|
||||
- Existing series missing art are backfilled automatically the next time you open the stats page.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(youtube): add mediaCache mode and safer stream media extraction by @ksyasuda in #130
|
||||
- fix(logs): use local date for log filenames and expand export redaction by @ksyasuda in #131
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
- fix(stats): parse v3 reading-aware known-word cache in stats server by @ksyasuda in #149
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
type LaneConfig = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude: Set<string>;
|
||||
};
|
||||
import { collectLaneFiles } from './test-lanes';
|
||||
|
||||
type LcovRecord = {
|
||||
sourceFile: string;
|
||||
@@ -18,64 +13,6 @@ type LcovRecord = {
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
const lanes: Record<string, LaneConfig> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
function getLaneFiles(laneName: string): string[] {
|
||||
const lane = lanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown coverage lane: ${laneName}`);
|
||||
}
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for coverage lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseCoverageDirArg(argv: string[]): string {
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--coverage-dir') {
|
||||
@@ -277,7 +214,13 @@ function runCoverageLane(): number {
|
||||
rmSync(shardRoot, { recursive: true, force: true });
|
||||
mkdirSync(shardRoot, { recursive: true });
|
||||
|
||||
const files = getLaneFiles(laneName);
|
||||
let files: string[];
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, laneName);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
return 1;
|
||||
}
|
||||
const reports: string[] = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,73 +1,153 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { collectLaneFiles } from './test-lanes.ts';
|
||||
|
||||
// Runs a test lane with per-file process isolation: one `bun test` process per
|
||||
// test file so a hanging test or leaked global in one file cannot poison the
|
||||
// rest of the lane. Use --single-process for the old all-in-one-process mode.
|
||||
//
|
||||
// Usage: bun scripts/run-test-lane.mjs <lane> [--jobs N] [--timeout-secs N] [--single-process]
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
|
||||
const lanes = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
exclude: new Set([
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
]),
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: new Set(['launcher/smoke.e2e.test.ts']),
|
||||
},
|
||||
};
|
||||
// Cap per-file buffered output so a long or noisy test cannot grow the string
|
||||
// without bound and exhaust memory.
|
||||
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
|
||||
function collectFiles(rootDir, includeSuffixes, excludeSet) {
|
||||
const out = [];
|
||||
const visit = (currentDir) => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
// Track spawned `bun test` children so we can kill them if the runner is
|
||||
// interrupted, avoiding orphaned in-flight test processes.
|
||||
const activeChildren = new Set();
|
||||
|
||||
function terminateChildren() {
|
||||
for (const child of activeChildren) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
activeChildren.clear();
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
terminateChildren();
|
||||
process.exit(130);
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { lane: undefined, jobs: 1, timeoutSecs: 300, singleProcess: false };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--jobs') {
|
||||
options.jobs = Math.max(1, Number(argv[(index += 1)]) || 1);
|
||||
} else if (arg === '--timeout-secs') {
|
||||
options.timeoutSecs = Math.max(1, Number(argv[(index += 1)]) || 300);
|
||||
} else if (arg === '--single-process') {
|
||||
options.singleProcess = true;
|
||||
} else if (!arg.startsWith('--') && options.lane === undefined) {
|
||||
options.lane = arg;
|
||||
} else {
|
||||
process.stderr.write(`Unknown argument: ${arg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function runFile(file, timeoutSecs) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn('bun', ['test', `./${file}`], { cwd: repoRoot });
|
||||
activeChildren.add(child);
|
||||
let output = '';
|
||||
let truncated = false;
|
||||
let timedOut = false;
|
||||
const append = (chunk) => {
|
||||
if (truncated) return;
|
||||
output += chunk;
|
||||
if (output.length > MAX_OUTPUT_BYTES) {
|
||||
output = `${output.slice(0, MAX_OUTPUT_BYTES)}\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]\n`;
|
||||
truncated = true;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
};
|
||||
child.stdout.on('data', append);
|
||||
child.stderr.on('data', append);
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, timeoutSecs * 1000);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
activeChildren.delete(child);
|
||||
resolvePromise({ file, code: timedOut ? 124 : (code ?? 1), output, timedOut });
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
activeChildren.delete(child);
|
||||
resolvePromise({ file, code: 1, output: String(error), timedOut: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runIsolated(files, options) {
|
||||
const failures = [];
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < files.length) {
|
||||
const file = files[nextIndex];
|
||||
nextIndex += 1;
|
||||
const result = await runFile(file, options.timeoutSecs);
|
||||
completed += 1;
|
||||
if (result.code !== 0) {
|
||||
failures.push(result);
|
||||
const reason = result.timedOut ? `timed out after ${options.timeoutSecs}s` : 'failed';
|
||||
process.stderr.write(`\n[${completed}/${files.length}] ${file} ${reason}\n`);
|
||||
process.stderr.write(result.output);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
await Promise.all(Array.from({ length: Math.min(options.jobs, files.length) }, worker));
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`\n${failures.length} of ${files.length} test files failed:\n`);
|
||||
for (const failure of failures) {
|
||||
process.stderr.write(` ${failure.file}${failure.timedOut ? ' (timeout)' : ''}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(`All ${files.length} test files passed.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lane = lanes[process.argv[2]];
|
||||
function runSingleProcess(files) {
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
if (!lane) {
|
||||
process.stderr.write(`Unknown test lane: ${process.argv[2] ?? '(missing)'}\n`);
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!options.lane) {
|
||||
process.stderr.write('Missing test lane name\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = lane.roots.flatMap((rootDir) => collectFiles(rootDir, lane.include, lane.exclude));
|
||||
|
||||
if (files.length === 0) {
|
||||
process.stderr.write(`No test files found for lane: ${process.argv[2]}\n`);
|
||||
let files;
|
||||
try {
|
||||
files = collectLaneFiles(repoRoot, options.lane);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', ['test', ...files.map((file) => `./${file}`)], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
if (options.singleProcess) {
|
||||
process.exit(runSingleProcess(files));
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
process.exit(await runIsolated(files, options));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
export type TestLane = {
|
||||
roots: string[];
|
||||
include: string[];
|
||||
exclude?: string[];
|
||||
extraFiles?: string[];
|
||||
};
|
||||
|
||||
// Single source of truth for test-lane membership. Consumed by
|
||||
// scripts/run-test-lane.mjs (plain runs) and scripts/run-coverage-lane.ts
|
||||
// (per-file coverage shards). Lanes discover files by directory so new test
|
||||
// files join their lane automatically.
|
||||
export const testLanes: Record<string, TestLane> = {
|
||||
'bun-src-full': {
|
||||
roots: ['src'],
|
||||
include: ['.test.ts', '.type-test.ts'],
|
||||
// Node-compat suites; their dist builds run via test:runtime:compat.
|
||||
exclude: [
|
||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
||||
'src/core/services/ipc.test.ts',
|
||||
'src/core/services/overlay-manager.test.ts',
|
||||
'src/main/config-validation.test.ts',
|
||||
'src/main/runtime/registry.test.ts',
|
||||
'src/main/runtime/startup-config.test.ts',
|
||||
],
|
||||
},
|
||||
config: {
|
||||
roots: ['src/config'],
|
||||
include: ['.test.ts'],
|
||||
extraFiles: ['src/generate-config-example.test.ts', 'src/verify-config-example.test.ts'],
|
||||
},
|
||||
launcher: {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
'bun-launcher-unit': {
|
||||
roots: ['launcher'],
|
||||
include: ['.test.ts'],
|
||||
exclude: ['launcher/smoke.e2e.test.ts'],
|
||||
},
|
||||
scripts: {
|
||||
roots: ['scripts'],
|
||||
include: ['.test.ts'],
|
||||
},
|
||||
stats: {
|
||||
roots: ['stats/src'],
|
||||
include: ['.test.ts', '.test.tsx'],
|
||||
},
|
||||
};
|
||||
|
||||
function collectFiles(
|
||||
repoRoot: string,
|
||||
rootDir: string,
|
||||
includeSuffixes: string[],
|
||||
excludeSet: Set<string>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (currentDir: string): void => {
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const fullPath = resolve(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relPath = relative(repoRoot, fullPath).replaceAll('\\', '/');
|
||||
if (excludeSet.has(relPath)) continue;
|
||||
if (includeSuffixes.some((suffix) => relPath.endsWith(suffix))) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(resolve(repoRoot, rootDir));
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
export function collectLaneFiles(repoRoot: string, laneName: string): string[] {
|
||||
const lane = testLanes[laneName];
|
||||
if (!lane) {
|
||||
throw new Error(`Unknown test lane: ${laneName}`);
|
||||
}
|
||||
const excludeSet = new Set(lane.exclude ?? []);
|
||||
const files = lane.roots.flatMap((rootDir) =>
|
||||
collectFiles(repoRoot, rootDir, lane.include, excludeSet),
|
||||
);
|
||||
for (const extra of lane.extraFiles ?? []) {
|
||||
if (!files.includes(extra)) files.push(extra);
|
||||
}
|
||||
files.sort();
|
||||
if (files.length === 0) {
|
||||
throw new Error(`No test files found for lane: ${laneName}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
@@ -703,8 +703,8 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text);
|
||||
isKnownWord(text: string, reading?: string): boolean {
|
||||
return this.knownWordCache.isKnownWord(text, reading);
|
||||
}
|
||||
|
||||
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||
|
||||
@@ -26,6 +26,7 @@ function createWorkflowHarness() {
|
||||
const deleted: number[][] = [];
|
||||
const addedTags: Array<{ noteIds: number[]; tags: string[] }> = [];
|
||||
const statuses: string[] = [];
|
||||
const osdMessages: string[] = [];
|
||||
const rememberedMerges: Array<{ deletedNoteId: number; keptNoteId: number }> = [];
|
||||
const mergeCalls: Array<{
|
||||
keepNoteId: number;
|
||||
@@ -112,7 +113,9 @@ function createWorkflowHarness() {
|
||||
statuses.push(message);
|
||||
},
|
||||
showNotification: async () => undefined,
|
||||
showOsdNotification: () => undefined,
|
||||
showOsdNotification: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
logError: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
truncateSentence: (value: string) => value,
|
||||
@@ -125,6 +128,7 @@ function createWorkflowHarness() {
|
||||
addedTags,
|
||||
rememberedMerges,
|
||||
statuses,
|
||||
osdMessages,
|
||||
mergeCalls,
|
||||
setManualChoice: (choice: typeof manualChoice) => {
|
||||
manualChoice = choice;
|
||||
@@ -191,6 +195,50 @@ test('FieldGroupingWorkflow manual mode returns false when callback unavailable'
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual cancel notifies exactly once', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping cancelled']);
|
||||
assert.equal(harness.updates.length, 0);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual mode notifies when the original card cannot be loaded', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
});
|
||||
harness.deps.client.notesInfo = async () => [];
|
||||
|
||||
const handled = await harness.workflow.handleManual(1, 2, {
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Expression: { value: 'word-2' },
|
||||
Sentence: { value: 'line-2' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(harness.osdMessages, ['Field grouping failed: original card not found']);
|
||||
});
|
||||
|
||||
test('FieldGroupingWorkflow manual keep-new uses new note as merge target and old note as source', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.setManualChoice({
|
||||
|
||||
@@ -98,6 +98,8 @@ export class FieldGroupingWorkflow {
|
||||
const originalNotesInfoResult = await this.deps.client.notesInfo([originalNoteId]);
|
||||
const originalNotesInfo = originalNotesInfoResult as FieldGroupingWorkflowNoteInfo[];
|
||||
if (!originalNotesInfo || originalNotesInfo.length === 0) {
|
||||
// handleManual owns all user-facing notifications; callers must not re-notify on false.
|
||||
this.deps.showOsdNotification('Field grouping failed: original card not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
|
||||
assert.deepEqual(harness.manualCalls, []);
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard shows a cancellation message when manual grouping is declined', async () => {
|
||||
test('triggerFieldGroupingForLastAddedCard does not re-notify when manual grouping is declined', async () => {
|
||||
const harness = createHarness({
|
||||
kikuFieldGrouping: 'manual',
|
||||
noteIds: [9],
|
||||
@@ -339,7 +339,9 @@ test('triggerFieldGroupingForLastAddedCard shows a cancellation message when man
|
||||
expression: 'word-9',
|
||||
},
|
||||
]);
|
||||
assert.equal(harness.calls.at(-1), 'osd:Field grouping cancelled');
|
||||
// The manual workflow already notifies about its outcome (cancelled/unavailable/failed);
|
||||
// the trigger wrapper re-notifying produced two "Field grouping cancelled" toasts.
|
||||
assert.equal(harness.calls.filter((call) => call === 'osd:Field grouping cancelled').length, 0);
|
||||
});
|
||||
|
||||
test('buildFieldGroupingPreview returns merged compact and full previews', async () => {
|
||||
|
||||
@@ -156,15 +156,14 @@ export class FieldGroupingService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handled = await this.deps.handleFieldGroupingManual(
|
||||
// The manual workflow owns all user-facing notifications for its outcomes (cancelled,
|
||||
// unavailable, failed) — re-notifying on a false return here duplicated them.
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
noteInfo,
|
||||
expressionText,
|
||||
);
|
||||
if (!handled) {
|
||||
this.deps.showOsdNotification('Field grouping cancelled');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('Error triggering field grouping:', (error as Error).message);
|
||||
|
||||
@@ -108,6 +108,55 @@ test('KnownWordCacheManager startLifecycle keeps fresh persisted cache without i
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
// v2 states carry no readings, so they load usable but stale to trigger a
|
||||
// prompt upgrade refresh.
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
getMsUntilNextRefresh: () => number;
|
||||
}
|
||||
).getMsUntilNextRefresh(),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager startLifecycle keeps fresh v3 persisted cache without immediate refresh', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
};
|
||||
const { manager, calls, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
try {
|
||||
Date.now = () => 120_000;
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
refreshedAtMs: 120_000,
|
||||
scope: '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
|
||||
notes: {
|
||||
'1': [{ word: '猫', reading: 'ねこ' }],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
manager.startLifecycle();
|
||||
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'ねこ'), true);
|
||||
assert.equal(manager.isKnownWord('猫', 'びょう'), false);
|
||||
assert.equal(calls.findNotes, 0);
|
||||
assert.equal(calls.notesInfo, 0);
|
||||
assert.equal(
|
||||
(
|
||||
manager as unknown as {
|
||||
@@ -263,13 +312,11 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
version: number;
|
||||
words: string[];
|
||||
notes?: Record<string, string[]>;
|
||||
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.version, 2);
|
||||
assert.deepEqual(persisted.words.sort(), ['鳥']);
|
||||
assert.equal(persisted.version, 3);
|
||||
assert.deepEqual(persisted.notes, {
|
||||
'1': ['鳥'],
|
||||
'1': [{ word: '鳥', reading: null }],
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -392,10 +439,10 @@ test('KnownWordCacheManager preserves cache state key captured before refresh wo
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
||||
scope: string;
|
||||
words: string[];
|
||||
notes: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||
};
|
||||
assert.equal(persisted.scope, '{"refreshMinutes":1,"scope":"all","fieldsWord":"Word"}');
|
||||
assert.deepEqual(persisted.words, ['猫']);
|
||||
assert.deepEqual(persisted.notes, { '1': [{ word: '猫', reading: null }] });
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -648,3 +695,243 @@ test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager disambiguates known words by note reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'ユカ'), true);
|
||||
// Same spelling, different word (床/とこ "bed") must not match.
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
// Note readings stay matchable as kana words.
|
||||
assert.equal(manager.isKnownWord('ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager does not match single-kana text by reading alone', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1, 2];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '夜' },
|
||||
'Word Reading': { value: 'よ' },
|
||||
},
|
||||
},
|
||||
{
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Word: { value: 'え' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
// よ must not count as known just because 夜 is read よ.
|
||||
assert.equal(manager.isKnownWord('よ'), false);
|
||||
assert.equal(manager.isKnownWord('ヨ'), false);
|
||||
assert.equal(manager.isKnownWord('夜'), true);
|
||||
assert.equal(manager.isKnownWord('夜', 'よ'), true);
|
||||
// A literal single-kana word entry still matches via the word map.
|
||||
assert.equal(manager.isKnownWord('え'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager probes reading fields even with per-deck word fields configured', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
decks: {
|
||||
'Kaishi 1.5k': ['Word'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesByQuery.set('deck:"Kaishi 1.5k"', [1]);
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
'Word Reading': { value: 'ゆか' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床', 'ゆか'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager matches words without readings in any reading', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: '床' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('床'), true);
|
||||
assert.equal(manager.isKnownWord('床', 'とこ'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager extracts word and reading from furigana word fields', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Word: { value: 'お 決[き]まり' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('お決まり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おきまり'), true);
|
||||
assert.equal(manager.isKnownWord('お決まり', 'おさだまり'), false);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager treats non-kana reading fields as words', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: '漢字' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('漢字'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager keeps kana-only reading notes matchable', async () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Word',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
},
|
||||
};
|
||||
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
clientState.findNotesResult = [1];
|
||||
clientState.notesInfoResult = [
|
||||
{
|
||||
noteId: 1,
|
||||
fields: {
|
||||
Reading: { value: 'たべる' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await manager.refresh(true);
|
||||
|
||||
assert.equal(manager.isKnownWord('たべる'), true);
|
||||
assert.equal(manager.isKnownWord('タベル'), true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,6 +5,16 @@ import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||
KnownWordEntry,
|
||||
convertKatakanaToHiragana,
|
||||
isReadingFieldName,
|
||||
knownWordEntryListsEqual,
|
||||
normalizeKnownReadingForLookup,
|
||||
normalizeKnownWordEntryList,
|
||||
parseFuriganaAnnotatedText,
|
||||
} from './known-word-entries';
|
||||
|
||||
const log = createLogger('anki').child('integration.known-word-cache');
|
||||
|
||||
@@ -79,7 +89,16 @@ interface KnownWordCacheStateV2 {
|
||||
readonly notes: Record<string, string[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2;
|
||||
interface KnownWordCacheStateV3 {
|
||||
readonly version: 3;
|
||||
readonly refreshedAtMs: number;
|
||||
readonly scope: string;
|
||||
readonly notes: Record<string, KnownWordEntry[]>;
|
||||
}
|
||||
|
||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
||||
|
||||
const NO_READING_KEY = '';
|
||||
|
||||
interface KnownWordCacheClient {
|
||||
findNotes: (
|
||||
@@ -106,9 +125,12 @@ type KnownWordQueryScope = {
|
||||
export class KnownWordCacheManager {
|
||||
private knownWordsLastRefreshedAtMs = 0;
|
||||
private knownWordsStateKey = '';
|
||||
private knownWords: Set<string> = new Set();
|
||||
private wordReferenceCounts = new Map<string, number>();
|
||||
private noteWordsById = new Map<number, string[]>();
|
||||
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
|
||||
// entries fail open: the word matches regardless of the token's reading.
|
||||
private wordReadingCounts = new Map<string, Map<string, number>>();
|
||||
// hiragana reading → note count, so kana tokens still match by reading alone.
|
||||
private readingCounts = new Map<string, number>();
|
||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private isRefreshingKnownWords = false;
|
||||
@@ -120,13 +142,35 @@ export class KnownWordCacheManager {
|
||||
);
|
||||
}
|
||||
|
||||
isKnownWord(text: string): boolean {
|
||||
isKnownWord(text: string, reading?: string): boolean {
|
||||
if (!this.isKnownWordCacheEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(text);
|
||||
return normalized.length > 0 ? this.knownWords.has(normalized) : false;
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const knownReadings = this.wordReadingCounts.get(normalized);
|
||||
if (knownReadings && knownReadings.size > 0) {
|
||||
const normalizedReading =
|
||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||
return (
|
||||
normalizedReading.length === 0 ||
|
||||
knownReadings.has(NO_READING_KEY) ||
|
||||
knownReadings.has(normalizedReading)
|
||||
);
|
||||
}
|
||||
|
||||
// Reading-only fallback, except for single-kana text: particles and
|
||||
// interjections (よ, ね, え…) would otherwise borrow the reading of an
|
||||
// unrelated note (夜「よ」, 絵「え」) and count as known.
|
||||
const hiragana = convertKatakanaToHiragana(normalized);
|
||||
if ([...hiragana].length === 1) {
|
||||
return false;
|
||||
}
|
||||
return this.readingCounts.has(hiragana);
|
||||
}
|
||||
|
||||
refresh(force = false): Promise<void> {
|
||||
@@ -173,7 +217,7 @@ export class KnownWordCacheManager {
|
||||
let didMutateCache = false;
|
||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
|
||||
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsStateKey) {
|
||||
@@ -185,8 +229,8 @@ export class KnownWordCacheManager {
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
const nextEntries = this.extractKnownWordEntriesFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextEntries);
|
||||
if (!changed) {
|
||||
return didMutateCache;
|
||||
}
|
||||
@@ -198,7 +242,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache updated in-session',
|
||||
`noteId=${noteInfo.noteId}`,
|
||||
`wordCount=${nextWords.length}`,
|
||||
`wordCount=${nextEntries.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
return true;
|
||||
@@ -236,11 +280,11 @@ export class KnownWordCacheManager {
|
||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
||||
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||
|
||||
if (this.noteWordsById.size === 0) {
|
||||
if (this.noteEntriesById.size === 0) {
|
||||
await this.rebuildFromCurrentNotes(currentNoteIds, noteFieldsById);
|
||||
} else {
|
||||
const currentNoteIdSet = new Set(currentNoteIds);
|
||||
for (const noteId of Array.from(this.noteWordsById.keys())) {
|
||||
for (const noteId of Array.from(this.noteEntriesById.keys())) {
|
||||
if (!currentNoteIdSet.has(noteId)) {
|
||||
this.removeNoteSnapshot(noteId);
|
||||
}
|
||||
@@ -251,7 +295,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(
|
||||
this.extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo,
|
||||
noteFieldsById.get(noteInfo.noteId),
|
||||
),
|
||||
@@ -266,7 +310,7 @@ export class KnownWordCacheManager {
|
||||
log.info(
|
||||
'Known-word cache refreshed',
|
||||
`noteCount=${currentNoteIds.length}`,
|
||||
`wordCount=${this.knownWords.size}`,
|
||||
`wordCount=${this.wordReadingCounts.size}`,
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||
@@ -291,7 +335,13 @@ export class KnownWordCacheManager {
|
||||
|
||||
private getDefaultKnownWordFields(): string[] {
|
||||
const configuredWordField = getConfiguredWordFieldName(this.deps.getConfig());
|
||||
return [...new Set([configuredWordField, 'Word', 'Reading', 'Word Reading'])];
|
||||
return this.withDefaultReadingFields([configuredWordField, 'Word']);
|
||||
}
|
||||
|
||||
// Reading fields are always probed (even when a deck configures explicit
|
||||
// word fields) so entries can carry the reading their note teaches.
|
||||
private withDefaultReadingFields(fields: string[]): string[] {
|
||||
return [...new Set([...fields, ...DEFAULT_KNOWN_WORD_READING_FIELDS])];
|
||||
}
|
||||
|
||||
private getKnownWordDecks(): string[] {
|
||||
@@ -337,7 +387,9 @@ export class KnownWordCacheManager {
|
||||
.filter((field) => field.length > 0),
|
||||
),
|
||||
];
|
||||
return normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields();
|
||||
return normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields();
|
||||
}
|
||||
|
||||
const deckFields = selectedDeckEntry[1];
|
||||
@@ -351,7 +403,7 @@ export class KnownWordCacheManager {
|
||||
),
|
||||
];
|
||||
if (normalizedFields.length > 0) {
|
||||
return normalizedFields;
|
||||
return this.withDefaultReadingFields(normalizedFields);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +434,10 @@ export class KnownWordCacheManager {
|
||||
: [];
|
||||
scopes.push({
|
||||
query: `deck:"${escapeAnkiSearchValue(trimmedDeckName)}"`,
|
||||
fields: normalizedFields.length > 0 ? normalizedFields : this.getDefaultKnownWordFields(),
|
||||
fields:
|
||||
normalizedFields.length > 0
|
||||
? this.withDefaultReadingFields(normalizedFields)
|
||||
: this.getDefaultKnownWordFields(),
|
||||
});
|
||||
}
|
||||
if (scopes.length > 0) {
|
||||
@@ -490,7 +545,7 @@ export class KnownWordCacheManager {
|
||||
for (const noteInfo of noteInfos) {
|
||||
this.replaceNoteSnapshot(
|
||||
noteInfo.noteId,
|
||||
this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
this.extractKnownWordEntriesFromNoteInfo(noteInfo, noteFieldsById.get(noteInfo.noteId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -519,56 +574,77 @@ export class KnownWordCacheManager {
|
||||
return noteInfos;
|
||||
}
|
||||
|
||||
private replaceNoteSnapshot(noteId: number, nextWords: string[]): boolean {
|
||||
const normalizedWords = normalizeKnownWordList(nextWords);
|
||||
const previousWords = this.noteWordsById.get(noteId) ?? [];
|
||||
if (knownWordListsEqual(previousWords, normalizedWords)) {
|
||||
private replaceNoteSnapshot(noteId: number, nextEntries: KnownWordEntry[]): boolean {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(nextEntries);
|
||||
const previousEntries = this.noteEntriesById.get(noteId) ?? [];
|
||||
if (knownWordEntryListsEqual(previousEntries, normalizedEntries)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
if (normalizedWords.length > 0) {
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
if (normalizedEntries.length > 0) {
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
} else {
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeNoteSnapshot(noteId: number): void {
|
||||
const previousWords = this.noteWordsById.get(noteId);
|
||||
if (!previousWords) {
|
||||
const previousEntries = this.noteEntriesById.get(noteId);
|
||||
if (!previousEntries) {
|
||||
return;
|
||||
}
|
||||
this.noteWordsById.delete(noteId);
|
||||
this.removeWordsFromCounts(previousWords);
|
||||
this.noteEntriesById.delete(noteId);
|
||||
this.removeEntriesFromCounts(previousEntries);
|
||||
}
|
||||
|
||||
private addWordsToCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) + 1;
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
this.knownWords.add(word);
|
||||
private addEntriesToCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
let readings = this.wordReadingCounts.get(entry.word);
|
||||
if (!readings) {
|
||||
readings = new Map();
|
||||
this.wordReadingCounts.set(entry.word, readings);
|
||||
}
|
||||
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
|
||||
if (entry.reading) {
|
||||
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeWordsFromCounts(words: string[]): void {
|
||||
for (const word of words) {
|
||||
const nextCount = (this.wordReferenceCounts.get(word) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
this.wordReferenceCounts.set(word, nextCount);
|
||||
} else {
|
||||
this.wordReferenceCounts.delete(word);
|
||||
this.knownWords.delete(word);
|
||||
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||
const readings = this.wordReadingCounts.get(entry.word);
|
||||
if (readings) {
|
||||
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
||||
if (nextCount > 0) {
|
||||
readings.set(readingKey, nextCount);
|
||||
} else {
|
||||
readings.delete(readingKey);
|
||||
if (readings.size === 0) {
|
||||
this.wordReadingCounts.delete(entry.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entry.reading) {
|
||||
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
||||
if (nextReadingCount > 0) {
|
||||
this.readingCounts.set(entry.reading, nextReadingCount);
|
||||
} else {
|
||||
this.readingCounts.delete(entry.reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearInMemoryState(): void {
|
||||
this.knownWords = new Set();
|
||||
this.wordReferenceCounts = new Map();
|
||||
this.noteWordsById = new Map();
|
||||
this.wordReadingCounts = new Map();
|
||||
this.readingCounts = new Map();
|
||||
this.noteEntriesById = new Map();
|
||||
this.knownWordsLastRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
@@ -601,32 +677,48 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
this.clearInMemoryState();
|
||||
if (parsed.version === 3) {
|
||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedEntries = normalizeKnownWordEntryList(entries);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.version === 2) {
|
||||
// Older states have no readings; load them reading-less (fail-open,
|
||||
// matching the old behavior) but leave the cache marked stale so the
|
||||
// next refresh upgrades entries with readings from Anki.
|
||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||
const noteId = Number.parseInt(noteIdKey, 10);
|
||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const normalizedWords = normalizeKnownWordList(words);
|
||||
if (normalizedWords.length === 0) {
|
||||
const normalizedEntries = normalizeKnownWordEntryList(
|
||||
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||
);
|
||||
if (normalizedEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
this.noteWordsById.set(noteId, normalizedWords);
|
||||
this.addWordsToCounts(normalizedWords);
|
||||
}
|
||||
} else {
|
||||
for (const value of parsed.words) {
|
||||
const normalized = this.normalizeKnownWordForLookup(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
this.knownWords.add(normalized);
|
||||
this.wordReferenceCounts.set(normalized, 1);
|
||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||
this.addEntriesToCounts(normalizedEntries);
|
||||
}
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
return;
|
||||
}
|
||||
|
||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||
this.knownWordsStateKey = parsed.scope;
|
||||
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||
} catch (error) {
|
||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||
this.clearInMemoryState();
|
||||
@@ -636,18 +728,17 @@ export class KnownWordCacheManager {
|
||||
|
||||
private persistKnownWordCacheState(): void {
|
||||
try {
|
||||
const notes: Record<string, string[]> = {};
|
||||
for (const [noteId, words] of this.noteWordsById.entries()) {
|
||||
if (words.length > 0) {
|
||||
notes[String(noteId)] = words;
|
||||
const notes: Record<string, KnownWordEntry[]> = {};
|
||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||
if (entries.length > 0) {
|
||||
notes[String(noteId)] = entries;
|
||||
}
|
||||
}
|
||||
|
||||
const state: KnownWordCacheStateV2 = {
|
||||
version: 2,
|
||||
const state: KnownWordCacheStateV3 = {
|
||||
version: 3,
|
||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||
scope: this.knownWordsStateKey,
|
||||
words: Array.from(this.knownWords),
|
||||
notes,
|
||||
};
|
||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||
@@ -659,14 +750,18 @@ export class KnownWordCacheManager {
|
||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1 && candidate.version !== 2) return false;
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.version === 2) {
|
||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||
if (typeof candidate.scope !== 'string') return false;
|
||||
if (candidate.version !== 3) {
|
||||
if (!Array.isArray(candidate.words)) return false;
|
||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (candidate.version === 2 || candidate.version === 3) {
|
||||
if (
|
||||
typeof candidate.notes !== 'object' ||
|
||||
candidate.notes === null ||
|
||||
@@ -674,10 +769,18 @@ export class KnownWordCacheManager {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isValidNoteEntry =
|
||||
candidate.version === 2
|
||||
? (entry: unknown): boolean => typeof entry === 'string'
|
||||
: (entry: unknown): boolean =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||
((entry as KnownWordEntry).reading === null ||
|
||||
typeof (entry as KnownWordEntry).reading === 'string');
|
||||
if (
|
||||
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||
(entry) =>
|
||||
Array.isArray(entry) && entry.every((word: unknown) => typeof word === 'string'),
|
||||
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@@ -686,11 +789,12 @@ export class KnownWordCacheManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractNormalizedKnownWordsFromNoteInfo(
|
||||
private extractKnownWordEntriesFromNoteInfo(
|
||||
noteInfo: KnownWordCacheNoteInfo,
|
||||
preferredFields = this.getConfiguredFields(),
|
||||
): string[] {
|
||||
const words: string[] = [];
|
||||
): KnownWordEntry[] {
|
||||
const wordValues: string[] = [];
|
||||
let noteReading: string | null = null;
|
||||
for (const preferredField of preferredFields) {
|
||||
const fieldName = resolveFieldName(Object.keys(noteInfo.fields), preferredField);
|
||||
if (!fieldName) continue;
|
||||
@@ -698,12 +802,36 @@ export class KnownWordCacheManager {
|
||||
const raw = noteInfo.fields[fieldName]?.value;
|
||||
if (!raw) continue;
|
||||
|
||||
const normalized = this.normalizeKnownWordForLookup(raw);
|
||||
if (normalized) {
|
||||
words.push(normalized);
|
||||
const cleaned = this.normalizeRawKnownWordValue(raw);
|
||||
if (!cleaned) continue;
|
||||
|
||||
if (isReadingFieldName(preferredField)) {
|
||||
const normalizedReading = normalizeKnownReadingForLookup(cleaned);
|
||||
if (normalizedReading) {
|
||||
noteReading ??= normalizedReading;
|
||||
continue;
|
||||
}
|
||||
// Non-kana content in a reading field: treat it as a word so decks
|
||||
// with repurposed reading fields keep matching (fail-open).
|
||||
}
|
||||
wordValues.push(cleaned);
|
||||
}
|
||||
return normalizeKnownWordList(words);
|
||||
|
||||
const entries: KnownWordEntry[] = [];
|
||||
for (const value of wordValues) {
|
||||
const parsed = parseFuriganaAnnotatedText(value);
|
||||
const word = parsed.text.trim().toLowerCase();
|
||||
if (!word) continue;
|
||||
const inlineReading = parsed.reading ? normalizeKnownReadingForLookup(parsed.reading) : '';
|
||||
entries.push({ word, reading: inlineReading || noteReading });
|
||||
}
|
||||
|
||||
// Kana-only notes (reading field but no word field) stay matchable.
|
||||
if (entries.length === 0 && noteReading) {
|
||||
entries.push({ word: noteReading, reading: noteReading });
|
||||
}
|
||||
|
||||
return normalizeKnownWordEntryList(entries);
|
||||
}
|
||||
|
||||
private normalizeRawKnownWordValue(value: string): string {
|
||||
@@ -718,22 +846,6 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKnownWordList(words: string[]): string[] {
|
||||
return [...new Set(words.map((word) => word.trim()).filter((word) => word.length > 0))].sort();
|
||||
}
|
||||
|
||||
function knownWordListsEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveFieldName(availableFieldNames: string[], preferredName: string): string | null {
|
||||
const exact = availableFieldNames.find((name) => name === preferredName);
|
||||
if (exact) return exact;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Known-word cache entries pair a word with the reading its Anki note teaches,
|
||||
// so spelling collisions across readings (e.g. 床/ゆか vs 床/とこ) don't mark
|
||||
// unrelated words as known. reading === null means the note carries no usable
|
||||
// reading and the word matches in any reading (fail-open).
|
||||
export interface KnownWordEntry {
|
||||
word: string;
|
||||
reading: string | null;
|
||||
}
|
||||
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const FURIGANA_SEGMENT_PATTERN = /([^\s \[\]]*)\[([^\]]*)\]/g;
|
||||
const FURIGANA_BRACKET_PATTERN = /\[[^\]]*\]/g;
|
||||
const WHITESPACE_PATTERN = /[\s ]+/g;
|
||||
|
||||
// Reading-bearing field names probed on every known-word note, in addition to
|
||||
// any configured word fields (covers Kaishi's "Word Reading" and Lapis's
|
||||
// "ExpressionReading" note types).
|
||||
export const DEFAULT_KNOWN_WORD_READING_FIELDS = [
|
||||
'Reading',
|
||||
'Word Reading',
|
||||
'ExpressionReading',
|
||||
'Expression Reading',
|
||||
];
|
||||
|
||||
export function isReadingFieldName(fieldName: string): boolean {
|
||||
return /reading/i.test(fieldName);
|
||||
}
|
||||
|
||||
export function convertKatakanaToHiragana(text: string): string {
|
||||
let converted = '';
|
||||
for (const char of text) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined && code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
|
||||
converted += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
|
||||
continue;
|
||||
}
|
||||
converted += char;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
function isHiraganaReadingChar(char: string): boolean {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (code >= 0x3041 && code <= 0x309f) || code === 0x30fc;
|
||||
}
|
||||
|
||||
// Splits Anki furigana syntax (`床[とこ]`, `お 決[き]まり`) into base text and
|
||||
// reading. Values without brackets pass through with reading null.
|
||||
export function parseFuriganaAnnotatedText(value: string): {
|
||||
text: string;
|
||||
reading: string | null;
|
||||
} {
|
||||
if (!value.includes('[')) {
|
||||
return { text: value, reading: null };
|
||||
}
|
||||
const text = value.replace(FURIGANA_BRACKET_PATTERN, '').replace(WHITESPACE_PATTERN, '');
|
||||
const reading = value.replace(FURIGANA_SEGMENT_PATTERN, '$2').replace(WHITESPACE_PATTERN, '');
|
||||
return { text, reading: reading.length > 0 ? reading : null };
|
||||
}
|
||||
|
||||
// Returns the hiragana-normalized reading, or '' when the value is not a
|
||||
// plausible kana reading (callers fall back to text-only matching then).
|
||||
export function normalizeKnownReadingForLookup(value: string): string {
|
||||
const parsed = parseFuriganaAnnotatedText(value.trim());
|
||||
const candidate = (parsed.reading ?? parsed.text).trim();
|
||||
if (!candidate) {
|
||||
return '';
|
||||
}
|
||||
const hiragana = convertKatakanaToHiragana(candidate);
|
||||
for (const char of hiragana) {
|
||||
if (!isHiraganaReadingChar(char)) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return hiragana;
|
||||
}
|
||||
|
||||
export function makeKnownWordEntryKey(entry: KnownWordEntry): string {
|
||||
return `${entry.word}\u0000${entry.reading ?? ''}`;
|
||||
}
|
||||
|
||||
export function normalizeKnownWordEntryList(entries: KnownWordEntry[]): KnownWordEntry[] {
|
||||
const byKey = new Map<string, KnownWordEntry>();
|
||||
for (const entry of entries) {
|
||||
const word = entry.word.trim();
|
||||
if (!word) {
|
||||
continue;
|
||||
}
|
||||
const reading = entry.reading?.trim() || null;
|
||||
const normalized: KnownWordEntry = { word, reading };
|
||||
byKey.set(makeKnownWordEntryKey(normalized), normalized);
|
||||
}
|
||||
return [...byKey.values()].sort((left, right) =>
|
||||
makeKnownWordEntryKey(left).localeCompare(makeKnownWordEntryKey(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function knownWordEntryListsEqual(left: KnownWordEntry[], right: KnownWordEntry[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (makeKnownWordEntryKey(left[index]!) !== makeKnownWordEntryKey(right[index]!)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -317,7 +317,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'object',
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.decks,
|
||||
description:
|
||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.',
|
||||
'Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.isKiku.fieldGrouping',
|
||||
|
||||
@@ -301,6 +301,7 @@ function createMockTracker(
|
||||
{ epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 },
|
||||
{ epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
|
||||
],
|
||||
ensureAnimeCoverArt: async () => false,
|
||||
getAnimeCoverArt: async (animeId: number) =>
|
||||
animeId === 1
|
||||
? {
|
||||
@@ -520,6 +521,44 @@ describe('stats server API routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions enriches known-word metrics from a v3 reading-aware cache', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const cachePath = path.join(dir, 'known-words.json');
|
||||
fs.writeFileSync(
|
||||
cachePath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
refreshedAtMs: 1,
|
||||
scope: 'deck:test',
|
||||
notes: {
|
||||
'101': [{ word: 'する', reading: 'する' }],
|
||||
'102': [{ word: '猫', reading: null }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getSessionWordsByLine: async (sessionId: number) =>
|
||||
sessionId === 1
|
||||
? [
|
||||
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
|
||||
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
{ knownWordCachePath: cachePath },
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/sessions?limit=5');
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
const first = body[0];
|
||||
assert.equal(first.knownWordsSeen, 2);
|
||||
assert.equal(first.knownWordRate, 66.7);
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
||||
let seenSessionId = 0;
|
||||
let seenLimit = 0;
|
||||
@@ -729,7 +768,7 @@ describe('stats server API routes', () => {
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepEqual(seenArgs, ['90d', 'month']);
|
||||
assert.deepEqual(seenArgs, ['90d', 'month', true]);
|
||||
assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
|
||||
assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
|
||||
});
|
||||
@@ -747,7 +786,7 @@ describe('stats server API routes', () => {
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['365d', 'month']);
|
||||
assert.deepEqual(seenArgs, ['365d', 'month', true]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
|
||||
@@ -763,7 +802,25 @@ describe('stats server API routes', () => {
|
||||
|
||||
const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day']);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day', true]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getTrendsDashboard: async (...args: unknown[]) => {
|
||||
seenArgs = args;
|
||||
return TRENDS_DASHBOARD;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request(
|
||||
'/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenArgs, ['30d', 'day', false]);
|
||||
});
|
||||
|
||||
it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
|
||||
@@ -976,8 +1033,9 @@ describe('stats server API routes', () => {
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('POST /api/stats/covers batches stored cover art without fetching missing art', async () => {
|
||||
it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
|
||||
let ensureCoverArtCalls = 0;
|
||||
const ensureAnimeCoverArtCalls: number[] = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getCoverArt: async (videoId: number) =>
|
||||
@@ -997,6 +1055,10 @@ describe('stats server API routes', () => {
|
||||
ensureCoverArtCalls += 1;
|
||||
return true;
|
||||
},
|
||||
ensureAnimeCoverArt: async (animeId: number) => {
|
||||
ensureAnimeCoverArtCalls.push(animeId);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1024,6 +1086,68 @@ describe('stats server API routes', () => {
|
||||
},
|
||||
});
|
||||
assert.equal(ensureCoverArtCalls, 0);
|
||||
assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
|
||||
});
|
||||
|
||||
it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
|
||||
let activeBackfills = 0;
|
||||
let maxActiveBackfills = 0;
|
||||
const pendingBackfills: Array<() => void> = [];
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getAnimeCoverArt: async () => null,
|
||||
ensureAnimeCoverArt: async () => {
|
||||
activeBackfills += 1;
|
||||
maxActiveBackfills = Math.max(maxActiveBackfills, activeBackfills);
|
||||
await new Promise<void>((resolve) => {
|
||||
pendingBackfills.push(resolve);
|
||||
});
|
||||
activeBackfills -= 1;
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ animeIds: [101, 102, 103, 104, 105] }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(maxActiveBackfills, 3);
|
||||
for (const resolveBackfill of pendingBackfills) {
|
||||
resolveBackfill();
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => {
|
||||
let fetched = false;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getAnimeCoverArt: async () =>
|
||||
fetched
|
||||
? {
|
||||
videoId: 1,
|
||||
anilistId: 21858,
|
||||
coverUrl: 'https://example.com/cover.jpg',
|
||||
coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
|
||||
titleRomaji: 'Little Witch Academia',
|
||||
titleEnglish: 'Little Witch Academia',
|
||||
episodesTotal: 25,
|
||||
fetchedAtMs: Date.now(),
|
||||
}
|
||||
: null,
|
||||
ensureAnimeCoverArt: async () => {
|
||||
fetched = true;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/1/cover');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'image/jpeg');
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
|
||||
|
||||
@@ -374,6 +374,46 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
|
||||
);
|
||||
});
|
||||
|
||||
test('handleCliCommand ensures background stats server for initial --start --background', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, background: true }), 'initial', deps);
|
||||
|
||||
assert.equal(ensured.length, 1);
|
||||
});
|
||||
|
||||
test('handleCliCommand ensures background stats server for second-instance --start --background', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true, background: true }), 'second-instance', deps);
|
||||
|
||||
assert.equal(ensured.length, 1);
|
||||
});
|
||||
|
||||
test('handleCliCommand does not ensure background stats server for foreground --start', () => {
|
||||
const ensured: number[] = [];
|
||||
const { deps } = createDeps({
|
||||
ensureBackgroundStatsServer: () => {
|
||||
ensured.push(1);
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ start: true }), 'initial', deps);
|
||||
|
||||
assert.equal(ensured.length, 0);
|
||||
});
|
||||
|
||||
test('handleCliCommand forces setup open for second-instance setup command', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
@@ -185,6 +186,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
ensureBackgroundStatsServer?: () => void;
|
||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
@@ -299,6 +301,7 @@ export function createCliCommandDepsRuntime(
|
||||
runUpdateCommand: options.app.runUpdateCommand,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: options.app.ensureBackgroundStatsServer,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
|
||||
@@ -393,6 +396,10 @@ export function handleCliCommand(
|
||||
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
|
||||
}
|
||||
|
||||
if (args.start && args.background) {
|
||||
deps.ensureBackgroundStatsServer?.();
|
||||
}
|
||||
|
||||
if (args.sessionAction) {
|
||||
dispatchCliSessionAction(
|
||||
args.sessionAction,
|
||||
|
||||
@@ -245,6 +245,12 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
restoreOnModalClose: 'kiku',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
// Abandonment also asks the renderer hosting the modal to close its dialog.
|
||||
{
|
||||
channel: 'kiku:field-grouping-cancel',
|
||||
restoreOnModalClose: undefined,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(waitCalls, [
|
||||
@@ -254,7 +260,76 @@ test('createFieldGroupingOverlayRuntime callback cancels and cleans up when kiku
|
||||
assert.deepEqual(warnings, [
|
||||
'Kiku field grouping modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
assert.deepEqual(closed, ['kiku']);
|
||||
// Once from the send-failure path inside sendKikuFieldGroupingRequest, once from the
|
||||
// callback's abandonment cleanup. The real runtime guards this via the restore set, so
|
||||
// the duplicate is a harmless no-op.
|
||||
assert.deepEqual(closed, ['kiku', 'kiku']);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('createFieldGroupingOverlayRuntime prepares overlay windows before opening the modal', async () => {
|
||||
// The field grouping modal must run the same prerequisites as every other modal
|
||||
// (openOverlayHostedModal) so it opens with the overlay runtime ready and the visible overlay
|
||||
// window present — otherwise on Hyprland it fails to sit above / focus over fullscreen mpv.
|
||||
const order: string[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
// The modal acknowledges open below, so the callback stays pending and arms its response
|
||||
// timeout; stub the timer so no real 90s handle leaks into the test runner.
|
||||
globalThis.setTimeout = (() => 0) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
try {
|
||||
const runtime = createFieldGroupingOverlayRuntime<'kiku'>({
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: () => null,
|
||||
setResolver: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<'kiku'>(),
|
||||
ensureOverlayStartupPrereqs: () => order.push('prereqs'),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => order.push('windows-ready'),
|
||||
sendToVisibleOverlay: (channel) => {
|
||||
order.push(`send:${channel}`);
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => {
|
||||
order.push('wait');
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Do not await: an acknowledged modal leaves the choice pending until the user responds.
|
||||
void runtime.createFieldGroupingCallback()({
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'b',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Let the async send + modal-open ack chain run.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.deepEqual(order, [
|
||||
'prereqs',
|
||||
'windows-ready',
|
||||
'send:kiku:field-grouping-request',
|
||||
'wait',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createFieldGroupingCallbackRuntime, sendToVisibleOverlayRuntime } from './overlay-bridge';
|
||||
|
||||
interface WindowLike {
|
||||
@@ -22,6 +23,15 @@ export interface FieldGroupingOverlayRuntimeOptions<T extends string> {
|
||||
waitForModalOpen?: (modal: T, timeoutMs: number) => Promise<boolean>;
|
||||
handleOverlayModalClosed?: (modal: T) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
/**
|
||||
* Prepare the overlay runtime and (re)create the visible overlay window before opening the
|
||||
* modal — the same prerequisites every other modal runs via `openOverlayHostedModal`. Without
|
||||
* them the field grouping modal can open with no sibling overlay window present, and on
|
||||
* Hyprland it then fails to sit above / take focus over fullscreen mpv the way the other
|
||||
* modals do.
|
||||
*/
|
||||
ensureOverlayStartupPrereqs?: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions?: () => void;
|
||||
sendToVisibleOverlay?: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -69,11 +79,16 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
data: KikuFieldGroupingRequestData,
|
||||
): Promise<boolean> => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
const sendOpen = (): boolean =>
|
||||
sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
const sendOpen = (): boolean => {
|
||||
// Match every other modal's open path (openOverlayHostedModal): ensure the overlay runtime
|
||||
// and visible overlay window exist before handing off to the dedicated modal window.
|
||||
options.ensureOverlayStartupPrereqs?.();
|
||||
options.ensureOverlayWindowsReadyForVisibilityActions?.();
|
||||
return sendToVisibleOverlay('kiku:field-grouping-request', data, {
|
||||
restoreOnModalClose: kikuModal,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!options.waitForModalOpen) {
|
||||
return sendOpen();
|
||||
@@ -102,6 +117,20 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
return opened;
|
||||
};
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
const kikuModal = 'kiku' as T;
|
||||
// Best-effort: tell the renderer hosting the modal to close its dialog. When the modal
|
||||
// lives in the dedicated modal window this is redundant with the teardown below, but it
|
||||
// also covers the case where the request was routed into the visible overlay.
|
||||
sendToVisibleOverlay(IPC_CHANNELS.event.kikuFieldGroupingCancel, undefined, {
|
||||
preferModalWindow: true,
|
||||
});
|
||||
// Reliable teardown of main-side modal state (restore set, main-overlay passthrough,
|
||||
// dedicated modal window). This is what recovers the frozen overlay when a grouping
|
||||
// request times out or fails to reach a visible modal.
|
||||
options.handleOverlayModalClosed?.(kikuModal);
|
||||
};
|
||||
|
||||
const createFieldGroupingCallback = (): ((
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>) => {
|
||||
@@ -112,6 +141,7 @@ export function createFieldGroupingOverlayRuntime<T extends string>(
|
||||
setResolver: options.setResolver,
|
||||
sendToVisibleOverlay,
|
||||
sendKikuFieldGroupingRequest,
|
||||
dismissModalUi,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
import { createFieldGroupingCallback } from './field-grouping';
|
||||
|
||||
function makeRequestData(): KikuFieldGroupingRequestData {
|
||||
return {
|
||||
original: {
|
||||
noteId: 1,
|
||||
expression: 'a',
|
||||
sentencePreview: 'a',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: true,
|
||||
},
|
||||
duplicate: {
|
||||
noteId: 2,
|
||||
expression: 'a',
|
||||
sentencePreview: 'b',
|
||||
hasAudio: false,
|
||||
hasImage: false,
|
||||
isOriginal: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors how main stores the resolver: it wraps the callback's resolver in a
|
||||
* sequence-guarded closure, so the value read back is never identity-equal to the
|
||||
* callback's own `finish`. The old `getResolver() === finish` clear-guard therefore
|
||||
* never matched and leaked the resolver, wedging every later grouping attempt.
|
||||
*/
|
||||
function createWrappedResolverStore() {
|
||||
let stored: ((choice: KikuFieldGroupingChoice) => void) | null = null;
|
||||
return {
|
||||
getResolver: () => stored,
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => {
|
||||
stored = resolver ? (choice) => resolver(choice) : null;
|
||||
},
|
||||
respond: (choice: KikuFieldGroupingChoice) => {
|
||||
stored?.(choice);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('field grouping callback clears the wrapped resolver after a renderer response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let visible = false;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => visible,
|
||||
setVisibleOverlayVisible: (next) => {
|
||||
visible = next;
|
||||
},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
|
||||
const choice: KikuFieldGroupingChoice = {
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: true,
|
||||
cancelled: false,
|
||||
};
|
||||
store.respond(choice);
|
||||
|
||||
assert.deepEqual(await pending, choice);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not reject the next request after a response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
const firstChoice = await first;
|
||||
assert.equal(firstChoice.cancelled, false);
|
||||
|
||||
// The second attempt must reach the renderer, not short-circuit to an instant cancel.
|
||||
const second = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.notEqual(store.getResolver(), null);
|
||||
store.respond({ keepNoteId: 2, deleteNoteId: 1, deleteDuplicate: false, cancelled: false });
|
||||
const secondChoice = await second;
|
||||
assert.equal(secondChoice.cancelled, false);
|
||||
assert.equal(secondChoice.keepNoteId, 2);
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the send fails', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on send failure', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback handles modal dismiss failures on timeout', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const originalConsoleError = console.error;
|
||||
console.error = () => {};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(store.getResolver(), null);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback reports modal dismiss failures', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
const errors: unknown[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
};
|
||||
try {
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => false,
|
||||
dismissModalUi: () => {
|
||||
throw new Error('dismiss failed');
|
||||
},
|
||||
});
|
||||
|
||||
await callback(makeRequestData());
|
||||
|
||||
assert.equal(errors.length, 1);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
test('field grouping callback dismisses the modal UI when the response times out', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 5,
|
||||
});
|
||||
|
||||
const result = await callback(makeRequestData());
|
||||
assert.equal(result.cancelled, true);
|
||||
assert.equal(dismissed, 1);
|
||||
assert.equal(store.getResolver(), null);
|
||||
});
|
||||
|
||||
test('field grouping callback does not dismiss the modal UI on a normal response', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => true,
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const pending = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await pending;
|
||||
assert.equal(dismissed, 0);
|
||||
});
|
||||
|
||||
test('field grouping callback rejects a concurrent request while one is pending', async () => {
|
||||
const store = createWrappedResolverStore();
|
||||
let sends = 0;
|
||||
let dismissed = 0;
|
||||
const callback = createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getResolver: store.getResolver,
|
||||
setResolver: store.setResolver,
|
||||
sendRequestToVisibleOverlay: () => {
|
||||
sends += 1;
|
||||
return true;
|
||||
},
|
||||
dismissModalUi: () => {
|
||||
dismissed += 1;
|
||||
},
|
||||
responseTimeoutMs: 10000,
|
||||
});
|
||||
|
||||
const first = callback(makeRequestData());
|
||||
await Promise.resolve();
|
||||
assert.equal(sends, 1);
|
||||
|
||||
const second = await callback(makeRequestData());
|
||||
assert.equal(second.cancelled, true);
|
||||
assert.equal(sends, 1);
|
||||
assert.equal(dismissed, 0);
|
||||
|
||||
store.respond({ keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: true, cancelled: false });
|
||||
await first;
|
||||
});
|
||||
@@ -1,21 +1,41 @@
|
||||
import { KikuFieldGroupingChoice, KikuFieldGroupingRequestData } from '../../types';
|
||||
|
||||
const DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS = 90000;
|
||||
|
||||
export function createFieldGroupingCallback(options: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
setResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
||||
sendRequestToVisibleOverlay: (data: KikuFieldGroupingRequestData) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Tears down the modal UI when the request is abandoned without a renderer response
|
||||
* (send failure or response timeout). Without this the dedicated modal window, its
|
||||
* restore-set entry, and the forced main-overlay passthrough stay orphaned — which on
|
||||
* Wayland leaves an invisible modal covering mpv and the overlay stuck unresponsive.
|
||||
*/
|
||||
dismissModalUi?: () => void;
|
||||
responseTimeoutMs?: number;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
const cancelledChoice = (): KikuFieldGroupingChoice => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
const dismissModalUi = (): void => {
|
||||
try {
|
||||
options.dismissModalUi?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss Kiku field grouping modal UI:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (data: KikuFieldGroupingRequestData): Promise<KikuFieldGroupingChoice> => {
|
||||
return new Promise((resolve) => {
|
||||
if (options.getResolver()) {
|
||||
resolve({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
resolve(cancelledChoice());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,18 +43,26 @@ export function createFieldGroupingCallback(options: {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (choice: KikuFieldGroupingChoice): void => {
|
||||
const finish = (choice: KikuFieldGroupingChoice, abandoned = false): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
if (options.getResolver() === finish) {
|
||||
options.setResolver(null);
|
||||
}
|
||||
// Always release the resolver. Callers (main) wrap this in a sequence-guarded
|
||||
// resolver, so an identity check against `finish` never matches and would leak
|
||||
// the resolver — blocking every later grouping attempt with an instant cancel.
|
||||
options.setResolver(null);
|
||||
resolve(choice);
|
||||
|
||||
// When abandoned without a renderer response, tear down the modal window/state
|
||||
// that the request path spun up. A normal response already routes through the
|
||||
// renderer's close handler, so only the abandon paths need this.
|
||||
if (abandoned) {
|
||||
dismissModalUi();
|
||||
}
|
||||
|
||||
if (!previousVisibleOverlay && options.getVisibleOverlayVisible()) {
|
||||
options.setVisibleOverlayVisible(false);
|
||||
}
|
||||
@@ -45,32 +73,17 @@ export function createFieldGroupingCallback(options: {
|
||||
(sent) => {
|
||||
if (settled) return;
|
||||
if (!sent) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
}
|
||||
}, 90000);
|
||||
}, options.responseTimeoutMs ?? DEFAULT_FIELD_GROUPING_RESPONSE_TIMEOUT_MS);
|
||||
},
|
||||
() => {
|
||||
finish({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: true,
|
||||
cancelled: true,
|
||||
});
|
||||
finish(cancelledChoice(), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildHyprlandPlacementDispatches,
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
findHyprlandWindowForPlacement,
|
||||
hasHyprlandWindowPlacementBoundsMismatch,
|
||||
shouldAttemptHyprlandWindowPlacement,
|
||||
@@ -203,6 +204,67 @@ test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows'
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports not-applicable off Hyprland', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
execFileSync: (() => {
|
||||
throw new Error('should not query the compositor when placement is not applicable');
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: false, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports pending when the client is not yet mapped', () => {
|
||||
// The window has not been mapped by the compositor yet, so no client matches. Callers use
|
||||
// this to keep retrying until the modal is actually placed above fullscreen mpv.
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 999,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: false, dispatched: false });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitleWithStatus reports the client found once mapped', () => {
|
||||
const status = ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: 'SubMiner Overlay Modal',
|
||||
platform: 'linux',
|
||||
env: { HYPRLAND_INSTANCE_SIGNATURE: 'abc' },
|
||||
pid: 456,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Overlay Modal',
|
||||
mapped: true,
|
||||
floating: false,
|
||||
pinned: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (args.join(' ') === '-j status') {
|
||||
return JSON.stringify({ configProvider: 'lua' });
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.deepEqual(status, { applicable: true, clientFound: true, dispatched: true });
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
|
||||
@@ -272,6 +272,21 @@ export function hasHyprlandWindowPlacementBoundsMismatch(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement outcome for a single reconcile attempt.
|
||||
* - `applicable`: false when not running under Hyprland (nothing to retry).
|
||||
* - `clientFound`: whether a mapped Hyprland client matched. On Wayland the window maps
|
||||
* asynchronously after `show()`, so early attempts can miss it — callers retry while this
|
||||
* is false so the modal actually gets promoted above fullscreen mpv instead of staying
|
||||
* invisible.
|
||||
* - `dispatched`: whether any hyprctl dispatch was issued.
|
||||
*/
|
||||
export interface HyprlandPlacementStatus {
|
||||
applicable: boolean;
|
||||
clientFound: boolean;
|
||||
dispatched: boolean;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
@@ -281,8 +296,20 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): boolean {
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus(options).dispatched;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitleWithStatus(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pid?: number;
|
||||
promote?: boolean;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): HyprlandPlacementStatus {
|
||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||
return false;
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
@@ -293,7 +320,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: options.title,
|
||||
});
|
||||
if (!client) {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
|
||||
const configProvider = detectHyprlandConfigProvider(run);
|
||||
@@ -329,9 +356,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||
}
|
||||
}
|
||||
return dispatches.length > 0;
|
||||
return { applicable: true, clientFound: true, dispatched: dispatches.length > 0 };
|
||||
} catch {
|
||||
return false;
|
||||
return { applicable: true, clientFound: false, dispatched: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4041,3 +4041,91 @@ test('markActiveVideoWatched returns false when no active session', async () =>
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange prefetches cover art at session start', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
const fetchedVideoIds: number[] = [];
|
||||
tracker.setCoverArtFetcher({
|
||||
fetchIfMissing: async (_db, videoId) => {
|
||||
fetchedVideoIds.push(videoId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.handleMediaChange('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
await waitForCondition(() => fetchedVideoIds.length > 0);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
sessionState: { videoId: number } | null;
|
||||
};
|
||||
assert.deepEqual(fetchedVideoIds, [privateApi.sessionState?.videoId]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureAnimeCoverArt fetches art via the latest video of the anime', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'little witch academia',
|
||||
'Little Witch Academia',
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
source_type,
|
||||
duration_ms,
|
||||
anime_id,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'local:/tmp/lwa-1.mkv', 'Little Witch Academia S01E01', 1, 0, 1, 1000, 1000),
|
||||
(2, 'local:/tmp/lwa-2.mkv', 'Little Witch Academia S01E02', 1, 0, 1, 1000, 1000);
|
||||
`);
|
||||
|
||||
const fetchedVideoIds: number[] = [];
|
||||
tracker.setCoverArtFetcher({
|
||||
fetchIfMissing: async (_db, videoId) => {
|
||||
fetchedVideoIds.push(videoId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tracker.ensureAnimeCoverArt(1);
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(fetchedVideoIds, [2]);
|
||||
|
||||
const missing = await tracker.ensureAnimeCoverArt(999);
|
||||
assert.equal(missing, false);
|
||||
assert.deepEqual(fetchedVideoIds, [2]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -571,8 +571,9 @@ export class ImmersionTrackerService {
|
||||
async getTrendsDashboard(
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): Promise<unknown> {
|
||||
return getTrendsDashboard(this.db, range, groupBy);
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
}
|
||||
|
||||
async getVocabularyStats(limit = 100, excludePos?: string[]): Promise<VocabularyStatsRow[]> {
|
||||
@@ -853,6 +854,22 @@ export class ImmersionTrackerService {
|
||||
this.coverArtFetcher = fetcher;
|
||||
}
|
||||
|
||||
async ensureAnimeCoverArt(animeId: number): Promise<boolean> {
|
||||
const existing = await this.getAnimeCoverArt(animeId);
|
||||
if (existing?.coverBlob) {
|
||||
return true;
|
||||
}
|
||||
const row = this.db
|
||||
.prepare(
|
||||
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ? ORDER BY video_id DESC LIMIT 1',
|
||||
)
|
||||
.get(animeId) as { videoId: number } | undefined;
|
||||
if (!row?.videoId) {
|
||||
return false;
|
||||
}
|
||||
return this.ensureCoverArt(row.videoId);
|
||||
}
|
||||
|
||||
async ensureCoverArt(videoId: number): Promise<boolean> {
|
||||
const existing = await this.getCoverArt(videoId);
|
||||
if (existing?.coverBlob) {
|
||||
@@ -878,8 +895,10 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const detail = getMediaDetail(this.db, videoId);
|
||||
const canonicalTitle = detail?.canonicalTitle?.trim();
|
||||
const titleRow = this.db
|
||||
.prepare('SELECT canonical_title AS canonicalTitle FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { canonicalTitle: string | null } | undefined;
|
||||
const canonicalTitle = titleRow?.canonicalTitle?.trim();
|
||||
if (!canonicalTitle) {
|
||||
return false;
|
||||
}
|
||||
@@ -1341,6 +1360,9 @@ export class ImmersionTrackerService {
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
if (!youtubeVideoId) {
|
||||
this.prefetchCoverArtAsync(sessionInfo.videoId);
|
||||
}
|
||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||
}
|
||||
|
||||
@@ -1923,6 +1945,24 @@ export class ImmersionTrackerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch cover art eagerly at session start (after anime metadata parsing
|
||||
// settles) so new series show art on the stats timeline without requiring a
|
||||
// visit to the series detail page first.
|
||||
private prefetchCoverArtAsync(videoId: number): void {
|
||||
const pendingMetadata = this.pendingAnimeMetadataUpdates.get(videoId);
|
||||
void (async () => {
|
||||
try {
|
||||
await pendingMetadata;
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
await this.ensureCoverArt(videoId);
|
||||
} catch (error) {
|
||||
this.logger.warn('Unable to prefetch cover art', (error as Error).message);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private updateVideoTitleForActiveSession(canonicalTitle: string): void {
|
||||
if (!this.sessionState) return;
|
||||
updateVideoTitleRecord(this.db, this.sessionState.videoId, canonicalTitle);
|
||||
|
||||
@@ -1003,6 +1003,121 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/30d-zerofill.mkv', {
|
||||
canonicalTitle: '30d Zero Fill',
|
||||
sourcePath: '/tmp/30d-zerofill.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const createdAtMs = '1772395200000';
|
||||
// Local "today" for the mocked clock is epoch day 20513. Seed only two
|
||||
// active days inside the 30-day window, leaving the rest empty.
|
||||
const todayEpochDay = 20513;
|
||||
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
insertDailyRollup.run(
|
||||
todayEpochDay - 10,
|
||||
videoId,
|
||||
1,
|
||||
45,
|
||||
4,
|
||||
120,
|
||||
3,
|
||||
createdAtMs,
|
||||
createdAtMs,
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'day');
|
||||
|
||||
// Exactly 30 calendar days, not just the two active ones.
|
||||
assert.equal(dashboard.activity.watchTime.length, 30);
|
||||
// Most recent day carries its seeded value; a gap day reads zero.
|
||||
assert.equal(dashboard.activity.watchTime.at(-1)?.value, 30);
|
||||
assert.equal(dashboard.activity.watchTime.at(-2)?.value, 0);
|
||||
// Only the two seeded days contribute to the totals.
|
||||
const nonZeroDays = dashboard.activity.watchTime.filter((point) => point.value > 0);
|
||||
assert.equal(nonZeroDays.length, 2);
|
||||
// Cumulative watch time still tops out at the sum of both active days.
|
||||
assert.equal(dashboard.progress.watchTime.at(-1)?.value, 75);
|
||||
// Every day-bucketed series shares the same 30-day axis.
|
||||
assert.equal(dashboard.progress.episodes.length, 30);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.episodes.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/no-zerofill.mkv', {
|
||||
canonicalTitle: 'No Zero Fill',
|
||||
sourcePath: '/tmp/no-zerofill.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const insertDailyRollup = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const createdAtMs = '1772395200000';
|
||||
const todayEpochDay = 20513;
|
||||
insertDailyRollup.run(todayEpochDay, videoId, 1, 30, 4, 100, 2, createdAtMs, createdAtMs);
|
||||
insertDailyRollup.run(
|
||||
todayEpochDay - 10,
|
||||
videoId,
|
||||
1,
|
||||
45,
|
||||
4,
|
||||
120,
|
||||
3,
|
||||
createdAtMs,
|
||||
createdAtMs,
|
||||
);
|
||||
|
||||
const filled = getTrendsDashboard(db, '30d', 'day', true);
|
||||
assert.equal(filled.activity.watchTime.length, 30);
|
||||
|
||||
const compact = getTrendsDashboard(db, '30d', 'day', false);
|
||||
// Only the two active days survive; no zero-filled gaps.
|
||||
assert.equal(compact.activity.watchTime.length, 2);
|
||||
assert.ok(compact.activity.watchTime.every((point) => point.value > 0));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'getTrendsDashboard supports 365d range and caps day buckets at 365',
|
||||
{ timeout: 20_000 },
|
||||
@@ -1266,7 +1381,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
|
||||
const dashboard = getTrendsDashboard(db, '30d', 'month');
|
||||
|
||||
assert.equal(dashboard.activity.watchTime.length, 2);
|
||||
// The 30d window (mocked now Mar 1 → cutoff Jan 31) spans three calendar
|
||||
// months, so January is zero-filled rather than dropped.
|
||||
assert.equal(dashboard.activity.watchTime.length, 3);
|
||||
assert.equal(dashboard.activity.watchTime[0]?.value, 0);
|
||||
assert.deepEqual(
|
||||
dashboard.progress.newWords.map((point) => point.label),
|
||||
dashboard.activity.watchTime.map((point) => point.label),
|
||||
|
||||
@@ -205,6 +205,59 @@ function resolveTrendAnimeTitle(value: {
|
||||
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
|
||||
}
|
||||
|
||||
// Ordered list of bucket keys (epoch days or YYYYMM months) covering the
|
||||
// selected range, so charts render one point per calendar bucket instead of
|
||||
// silently collapsing to only the buckets that have activity. Returns null for
|
||||
// the unbounded "all" range, where builders fall back to the buckets in data.
|
||||
function buildBucketAxis(
|
||||
db: DatabaseSync,
|
||||
groupBy: TrendGroupBy,
|
||||
cutoffMs: string | null,
|
||||
referenceMs: string,
|
||||
): number[] | null {
|
||||
if (cutoffMs === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (groupBy === 'month') {
|
||||
const startKey = getLocalMonthKey(db, cutoffMs);
|
||||
const endKey = getLocalMonthKey(db, referenceMs);
|
||||
const keys: number[] = [];
|
||||
let year = Math.floor(startKey / 100);
|
||||
let month = startKey % 100;
|
||||
const endYear = Math.floor(endKey / 100);
|
||||
const endMonth = endKey % 100;
|
||||
while (year < endYear || (year === endYear && month <= endMonth)) {
|
||||
keys.push(year * 100 + month);
|
||||
month += 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const startDay = getLocalEpochDay(db, cutoffMs);
|
||||
const endDay = getLocalEpochDay(db, referenceMs);
|
||||
const keys: number[] = [];
|
||||
for (let day = startDay; day <= endDay; day += 1) {
|
||||
keys.push(day);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Project a bucket→value map onto the axis, filling absent buckets with zero.
|
||||
// Without an axis (the "all" range) it falls back to the populated buckets in
|
||||
// ascending order, preserving the previous behaviour.
|
||||
function fillAxisPoints(
|
||||
axis: number[] | null,
|
||||
valueByBucket: Map<number, number>,
|
||||
): TrendChartPoint[] {
|
||||
const keys = axis ?? [...valueByBucket.keys()].sort((left, right) => left - right);
|
||||
return keys.map((key) => ({ label: makeTrendLabel(key), value: valueByBucket.get(key) ?? 0 }));
|
||||
}
|
||||
|
||||
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
let sum = 0;
|
||||
return points.map((point) => {
|
||||
@@ -216,7 +269,7 @@ function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
});
|
||||
}
|
||||
|
||||
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[], axis: number[] | null) {
|
||||
const byKey = new Map<
|
||||
number,
|
||||
{ activeMin: number; cards: number; words: number; sessions: number }
|
||||
@@ -236,15 +289,17 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||
}
|
||||
|
||||
return Array.from(byKey.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([key, value]) => ({
|
||||
const keys = axis ?? Array.from(byKey.keys()).sort((left, right) => left - right);
|
||||
return keys.map((key) => {
|
||||
const value = byKey.get(key) ?? { activeMin: 0, cards: 0, words: 0, sessions: 0 };
|
||||
return {
|
||||
label: makeTrendLabel(key),
|
||||
activeMin: Math.round(value.activeMin),
|
||||
cards: value.cards,
|
||||
words: value.words,
|
||||
sessions: value.sessions,
|
||||
}));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
||||
@@ -289,40 +344,24 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
||||
}));
|
||||
}
|
||||
|
||||
function dayLabel(epochDay: number): string {
|
||||
const { month, day } = dayPartsFromEpochDay(epochDay);
|
||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||
}
|
||||
|
||||
function buildSessionSeriesByDay(
|
||||
function buildSessionSeries(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, number>();
|
||||
const byBucket = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
|
||||
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
|
||||
byBucket.set(bucketKey, (byBucket.get(bucketKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value }));
|
||||
}
|
||||
|
||||
function buildSessionSeriesByMonth(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
getValue: (session: TrendSessionMetricRow) => number,
|
||||
): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value }));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
function buildLookupsPerHundredWords(
|
||||
sessions: TrendSessionMetricRow[],
|
||||
groupBy: TrendGroupBy,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const lookupsByBucket = new Map<number, number>();
|
||||
const wordsByBucket = new Map<number, number>();
|
||||
@@ -339,15 +378,12 @@ function buildLookupsPerHundredWords(
|
||||
);
|
||||
}
|
||||
|
||||
return Array.from(lookupsByBucket.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([bucketKey, lookups]) => {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
return {
|
||||
label: groupBy === 'month' ? makeTrendLabel(bucketKey) : dayLabel(bucketKey),
|
||||
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
|
||||
};
|
||||
});
|
||||
const ratioByBucket = new Map<number, number>();
|
||||
for (const [bucketKey, lookups] of lookupsByBucket) {
|
||||
const words = wordsByBucket.get(bucketKey) ?? 0;
|
||||
ratioByBucket.set(bucketKey, words > 0 ? +((lookups / words) * 100).toFixed(1) : 0);
|
||||
}
|
||||
return fillAxisPoints(axis, ratioByBucket);
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: TrendPerAnimePoint[]): TrendPerAnimePoint[] {
|
||||
@@ -557,46 +593,26 @@ function buildEpisodesPerAnimeFromDailyRollups(
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildEpisodesPerDayFromDailyRollups(
|
||||
function buildEpisodesSeriesFromRollups(
|
||||
rollups: ImmersionSessionRollupRow[],
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, Set<number>>();
|
||||
const byBucket = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byDay.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
const videoIds = byBucket.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byDay.set(rollup.rollupDayOrMonth, videoIds);
|
||||
byBucket.set(rollup.rollupDayOrMonth, videoIds);
|
||||
}
|
||||
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([epochDay, videoIds]) => ({
|
||||
label: dayLabel(epochDay),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, Set<number>>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
if (rollup.videoId === null) {
|
||||
continue;
|
||||
}
|
||||
const videoIds = byMonth.get(rollup.rollupDayOrMonth) ?? new Set<number>();
|
||||
videoIds.add(rollup.videoId);
|
||||
byMonth.set(rollup.rollupDayOrMonth, videoIds);
|
||||
const counts = new Map<number, number>();
|
||||
for (const [bucketKey, videoIds] of byBucket) {
|
||||
counts.set(bucketKey, videoIds.size);
|
||||
}
|
||||
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([monthKey, videoIds]) => ({
|
||||
label: makeTrendLabel(monthKey),
|
||||
value: videoIds.size,
|
||||
}));
|
||||
return fillAxisPoints(axis, counts);
|
||||
}
|
||||
|
||||
function getTrendSessionMetrics(
|
||||
@@ -639,7 +655,11 @@ function getTrendSessionMetrics(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerDay(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: string | null,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -662,13 +682,15 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendCh
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: dayLabel(row.epochDay),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.epochDay, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerMonth(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: string | null,
|
||||
axis: number[] | null,
|
||||
): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
@@ -691,16 +713,15 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): Trend
|
||||
wordCount: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: makeTrendLabel(row.monthKey),
|
||||
value: row.wordCount,
|
||||
}));
|
||||
const byBucket = new Map<number, number>(rows.map((row) => [row.monthKey, row.wordCount]));
|
||||
return fillAxisPoints(axis, byBucket);
|
||||
}
|
||||
|
||||
export function getTrendsDashboard(
|
||||
db: DatabaseSync,
|
||||
range: TrendRange = '30d',
|
||||
groupBy: TrendGroupBy = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): TrendsDashboardQueryResult {
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
||||
@@ -708,6 +729,11 @@ export function getTrendsDashboard(
|
||||
const useMonthlyBuckets = groupBy === 'month';
|
||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||
// A null axis makes the builders fall back to only the buckets present in the
|
||||
// data; the contiguous axis zero-fills every calendar bucket in the window.
|
||||
const bucketAxis = fillEmptyBuckets
|
||||
? buildBucketAxis(db, groupBy, cutoffMs, currentDbTimestamp())
|
||||
: null;
|
||||
|
||||
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
|
||||
const sessions = getTrendSessionMetrics(db, cutoffMs);
|
||||
@@ -716,7 +742,7 @@ export function getTrendsDashboard(
|
||||
dailyRollups.map((rollup) => rollup.videoId),
|
||||
);
|
||||
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups, bucketAxis);
|
||||
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||
const activity = {
|
||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
@@ -751,22 +777,23 @@ export function getTrendsDashboard(
|
||||
sessions: accumulatePoints(activity.sessions),
|
||||
words: accumulatePoints(activity.words),
|
||||
newWords: accumulatePoints(
|
||||
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs),
|
||||
useMonthlyBuckets
|
||||
? buildNewWordsPerMonth(db, cutoffMs, bucketAxis)
|
||||
: buildNewWordsPerDay(db, cutoffMs, bucketAxis),
|
||||
),
|
||||
cards: accumulatePoints(activity.cards),
|
||||
episodes: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildEpisodesPerMonthFromRollups(monthlyRollups)
|
||||
: buildEpisodesPerDayFromDailyRollups(dailyRollups),
|
||||
buildEpisodesSeriesFromRollups(
|
||||
useMonthlyBuckets ? monthlyRollups : dailyRollups,
|
||||
bucketAxis,
|
||||
),
|
||||
),
|
||||
lookups: accumulatePoints(
|
||||
useMonthlyBuckets
|
||||
? buildSessionSeriesByMonth(sessions, (session) => session.yomitanLookupCount)
|
||||
: buildSessionSeriesByDay(sessions, (session) => session.yomitanLookupCount),
|
||||
buildSessionSeries(sessions, groupBy, (session) => session.yomitanLookupCount, bucketAxis),
|
||||
),
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy, bucketAxis),
|
||||
cardsPerHour: efficiency.cardsPerHour,
|
||||
readingSpeed: efficiency.readingSpeed,
|
||||
},
|
||||
|
||||
@@ -65,12 +65,14 @@ export function createFieldGroupingCallbackRuntime<T extends string>(options: {
|
||||
runtimeOptions?: { restoreOnModalClose?: T; preferModalWindow?: boolean },
|
||||
) => boolean;
|
||||
sendKikuFieldGroupingRequest?: (data: KikuFieldGroupingRequestData) => Promise<boolean>;
|
||||
dismissModalUi?: () => void;
|
||||
}): (data: KikuFieldGroupingRequestData) => Promise<KikuFieldGroupingChoice> {
|
||||
return createFieldGroupingCallback({
|
||||
getVisibleOverlayVisible: options.getVisibleOverlayVisible,
|
||||
setVisibleOverlayVisible: options.setVisibleOverlayVisible,
|
||||
getResolver: options.getResolver,
|
||||
setResolver: options.setResolver,
|
||||
dismissModalUi: options.dismissModalUi,
|
||||
sendRequestToVisibleOverlay: (data) =>
|
||||
options.sendKikuFieldGroupingRequest
|
||||
? options.sendKikuFieldGroupingRequest(data)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { RuntimeOptionState, WindowGeometry } from '../../types';
|
||||
import { updateOverlayWindowBounds } from './overlay-window';
|
||||
import type { HyprlandPlacementStatus } from './hyprland-window-placement';
|
||||
|
||||
export interface OverlayManager {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
@@ -9,7 +10,7 @@ export interface OverlayManager {
|
||||
setModalWindow: (window: BrowserWindow | null) => void;
|
||||
getOverlayWindow: () => BrowserWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
@@ -29,9 +30,12 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
let visibleOverlayVisible = false;
|
||||
const applyOverlayBounds = options.updateOverlayWindowBounds ?? updateOverlayWindowBounds;
|
||||
|
||||
const updateWindowBounds = (geometry: WindowGeometry, window: BrowserWindow | null): void => {
|
||||
const updateWindowBounds = (
|
||||
geometry: WindowGeometry,
|
||||
window: BrowserWindow | null,
|
||||
): HyprlandPlacementStatus => {
|
||||
const promote = window ? (options.shouldPromoteWindowOnBoundsUpdate?.(window) ?? true) : true;
|
||||
applyOverlayBounds(geometry, window, { promote });
|
||||
return applyOverlayBounds(geometry, window, { promote });
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -47,9 +51,7 @@ export function createOverlayManager(options: OverlayManagerOptions = {}): Overl
|
||||
setOverlayWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, mainWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => {
|
||||
updateWindowBounds(geometry, modalWindow);
|
||||
},
|
||||
setModalWindowBounds: (geometry) => updateWindowBounds(geometry, modalWindow),
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
handleOverlayWindowBlurred,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
|
||||
import {
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
ensureHyprlandWindowFloatingByTitleWithStatus,
|
||||
type HyprlandPlacementStatus,
|
||||
} from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
@@ -54,8 +58,10 @@ export function updateOverlayWindowBounds(
|
||||
options: {
|
||||
promote?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
if (!geometry || !window || window.isDestroyed()) return;
|
||||
): HyprlandPlacementStatus {
|
||||
if (!geometry || !window || window.isDestroyed()) {
|
||||
return { applicable: false, clientFound: false, dispatched: false };
|
||||
}
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||
geometry,
|
||||
process.platform,
|
||||
@@ -63,7 +69,7 @@ export function updateOverlayWindowBounds(
|
||||
window,
|
||||
);
|
||||
window.setBounds(bounds);
|
||||
ensureHyprlandWindowFloatingByTitle({
|
||||
return ensureHyprlandWindowFloatingByTitleWithStatus({
|
||||
title: window.getTitle(),
|
||||
bounds,
|
||||
promote: options.promote,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
|
||||
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Math.floor(n);
|
||||
return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit);
|
||||
}
|
||||
|
||||
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const ids = new Set<number>();
|
||||
for (const rawId of raw) {
|
||||
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
ids.add(Math.floor(id));
|
||||
if (ids.size >= maxItems) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function coverImagePayload(
|
||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||
): StatsCoverImagePayload {
|
||||
if (!art?.coverBlob) return null;
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
const contentType = detectImageContentType(bytes);
|
||||
return {
|
||||
contentType,
|
||||
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function detectImageContentType(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function createLimitedTaskRunner(maxConcurrentTasks: number): (task: () => Promise<void>) => void {
|
||||
const queue: Array<() => Promise<void>> = [];
|
||||
let activeTasks = 0;
|
||||
|
||||
const drain = (): void => {
|
||||
while (activeTasks < maxConcurrentTasks && queue.length > 0) {
|
||||
const task = queue.shift();
|
||||
if (!task) return;
|
||||
activeTasks += 1;
|
||||
void task()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
activeTasks -= 1;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (task: () => Promise<void>): void => {
|
||||
queue.push(task);
|
||||
drain();
|
||||
};
|
||||
}
|
||||
|
||||
export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerService): void {
|
||||
const enqueueAnimeCoverBackfill = createLimitedTaskRunner(MAX_BACKGROUND_ANIME_COVER_FETCHES);
|
||||
|
||||
app.post('/api/stats/covers', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||
const media: Record<number, StatsCoverImagePayload> = {};
|
||||
|
||||
await Promise.all(
|
||||
animeIds.map(async (animeId) => {
|
||||
const art = await tracker.getAnimeCoverArt(animeId);
|
||||
if (!art?.coverBlob) {
|
||||
enqueueAnimeCoverBackfill(async () => {
|
||||
await tracker.ensureAnimeCoverArt(animeId);
|
||||
});
|
||||
}
|
||||
anime[animeId] = coverImagePayload(art);
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
videoIds.map(async (videoId) => {
|
||||
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 404);
|
||||
let art = await tracker.getAnimeCoverArt(animeId);
|
||||
if (!art?.coverBlob) {
|
||||
await tracker.ensureAnimeCoverArt(animeId);
|
||||
art = await tracker.getAnimeCoverArt(animeId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 404);
|
||||
let art = await tracker.getCoverArt(videoId);
|
||||
if (!art?.coverBlob) {
|
||||
await tracker.ensureCoverArt(videoId);
|
||||
art = await tracker.getCoverArt(videoId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../anki-field-config.js';
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
@@ -51,16 +52,6 @@ type StatsExcludedWordPayload = {
|
||||
reading: string;
|
||||
};
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
@@ -81,6 +72,12 @@ function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
|
||||
return raw === 'month' ? 'month' : 'day';
|
||||
}
|
||||
|
||||
// Defaults to true (zero-fill empty calendar buckets); only an explicit
|
||||
// "false" opts into the compact, data-only view.
|
||||
function parseTrendFillEmpty(raw: string | undefined): boolean {
|
||||
return raw !== 'false';
|
||||
}
|
||||
|
||||
function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = raw
|
||||
@@ -107,62 +104,6 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
|
||||
return words;
|
||||
}
|
||||
|
||||
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const ids = new Set<number>();
|
||||
for (const rawId of raw) {
|
||||
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
ids.add(Math.floor(id));
|
||||
if (ids.size >= maxItems) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function coverImagePayload(
|
||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||
): StatsCoverImagePayload {
|
||||
if (!art?.coverBlob) return null;
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
const contentType = detectImageContentType(bytes);
|
||||
return {
|
||||
contentType,
|
||||
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function detectImageContentType(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function resolveStatsNoteFieldName(
|
||||
noteInfo: StatsServerNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -320,10 +261,25 @@ function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
||||
version?: number;
|
||||
words?: string[];
|
||||
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
|
||||
};
|
||||
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||
return new Set(raw.words);
|
||||
}
|
||||
// v3 stores reading-aware entries per note; stats rows only carry
|
||||
// headwords, so flatten to a word set (reading-agnostic, fail-open).
|
||||
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
|
||||
const words = new Set<string>();
|
||||
for (const entries of Object.values(raw.notes)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (const entry of entries) {
|
||||
if (entry && typeof entry.word === 'string' && entry.word) {
|
||||
words.add(entry.word);
|
||||
}
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -685,7 +641,8 @@ export function createStatsApp(
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy));
|
||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
@@ -1010,58 +967,7 @@ export function createStatsApp(
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/stats/covers', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||
const media: Record<number, StatsCoverImagePayload> = {};
|
||||
|
||||
await Promise.all(
|
||||
animeIds.map(async (animeId) => {
|
||||
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
videoIds.map(async (videoId) => {
|
||||
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 404);
|
||||
const art = await tracker.getAnimeCoverArt(animeId);
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId/cover', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 404);
|
||||
let art = await tracker.getCoverArt(videoId);
|
||||
if (!art?.coverBlob) {
|
||||
await tracker.ensureCoverArt(videoId);
|
||||
art = await tracker.getCoverArt(videoId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
});
|
||||
registerStatsCoverRoutes(app, tracker);
|
||||
|
||||
app.get('/api/stats/episode/:videoId/detail', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
|
||||
@@ -1821,7 +1821,7 @@ test('tokenizeSubtitle returns null tokens when mecab throws', async () => {
|
||||
assert.deepEqual(result, { text: '猫です', tokens: null });
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle uses Yomitan parser result when available and drops no-headword groups', async () => {
|
||||
test('tokenizeSubtitle uses Yomitan parser result and keeps no-headword groups as surface tokens', async () => {
|
||||
const parserWindow = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
@@ -1859,10 +1859,12 @@ test('tokenizeSubtitle uses Yomitan parser result when available and drops no-he
|
||||
);
|
||||
|
||||
assert.equal(result.text, '猫です');
|
||||
assert.equal(result.tokens?.length, 1);
|
||||
assert.equal(result.tokens?.length, 2);
|
||||
assert.equal(result.tokens?.[0]?.surface, '猫');
|
||||
assert.equal(result.tokens?.[0]?.reading, 'ねこ');
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.surface, 'です');
|
||||
assert.equal(result.tokens?.[1]?.headword, 'です');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface TokenizerServiceDeps {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -77,7 +77,7 @@ export interface TokenizerDepsRuntimeOptions {
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||
getKnownWordsEnabled?: () => boolean;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
@@ -129,7 +129,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
|
||||
function getKnownWordLookup(
|
||||
deps: TokenizerServiceDeps,
|
||||
options: TokenizerAnnotationOptions,
|
||||
): (text: string) => boolean {
|
||||
): (text: string, reading?: string) => boolean {
|
||||
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
|
||||
return () => false;
|
||||
}
|
||||
@@ -150,7 +150,8 @@ function hasAnyAnnotationEnabled(options: TokenizerAnnotationOptions): boolean {
|
||||
options.knownWordsEnabled ||
|
||||
options.nPlusOneEnabled ||
|
||||
options.jlptEnabled ||
|
||||
options.frequencyEnabled
|
||||
options.frequencyEnabled ||
|
||||
options.nameMatchEnabled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -723,6 +724,7 @@ async function parseWithYomitanInternalParser(
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
headwordReading: token.headwordReading,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
partOfSpeech: posMetadata.partOfSpeech,
|
||||
|
||||
@@ -33,6 +33,50 @@ function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): AnnotationStage
|
||||
};
|
||||
}
|
||||
|
||||
test('annotateTokens keeps name matches on tokens the POS noise filter would strip', () => {
|
||||
// MeCab tags 平 as 接頭詞 in contexts like あっ 平 これ…, which is in the
|
||||
// POS1 exclusion list; a confirmed character-name match must survive it.
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '平',
|
||||
headword: '平',
|
||||
reading: 'たいら',
|
||||
pos1: '接頭詞',
|
||||
isNameMatch: true,
|
||||
}),
|
||||
makeToken({
|
||||
surface: '平',
|
||||
headword: '平',
|
||||
reading: 'ひら',
|
||||
pos1: '接頭詞',
|
||||
isNameMatch: false,
|
||||
jlptLevel: 'N1',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), { nameMatchEnabled: true });
|
||||
|
||||
assert.equal(result[0]?.isNameMatch, true);
|
||||
assert.equal(result[1]?.isNameMatch, false);
|
||||
assert.equal(result[1]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens strips name matches from POS-excluded tokens when name matching is disabled', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '平',
|
||||
headword: '平',
|
||||
reading: 'たいら',
|
||||
pos1: '接頭詞',
|
||||
isNameMatch: true,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps(), { nameMatchEnabled: false });
|
||||
|
||||
assert.equal(result[0]?.isNameMatch, false);
|
||||
});
|
||||
|
||||
test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })];
|
||||
const isKnownWord = (text: string): boolean => text === '食べる';
|
||||
@@ -56,6 +100,70 @@ test('annotateTokens known-word match mode uses headword vs surface', () => {
|
||||
assert.equal(surfaceResult[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens passes dictionary-form reading so spelling collisions stay unknown', () => {
|
||||
// とこ (colloquial ところ) resolves to headword 床/とこ; a known 床/ゆか card
|
||||
// must not mark it known (#138 regression).
|
||||
const cache = new Map([['床', 'ゆか']]);
|
||||
const isKnownWord = (text: string, reading?: string): boolean => {
|
||||
if (!cache.has(text)) {
|
||||
return false;
|
||||
}
|
||||
return reading === undefined || cache.get(text) === reading;
|
||||
};
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'とこ',
|
||||
headword: '床',
|
||||
reading: 'とこ',
|
||||
headwordReading: 'とこ',
|
||||
endPos: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps inflected known words matched via headword reading', () => {
|
||||
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||
text === '行く' && (reading === undefined || reading === 'いく');
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '行きたい',
|
||||
headword: '行く',
|
||||
reading: 'いきたい',
|
||||
headwordReading: 'いく',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens omits reading for headword match when token lacks headword reading and is inflected', () => {
|
||||
// MeCab tokens have no dictionary-form reading; the surface reading of an
|
||||
// inflected form must not be compared against the note's dictionary reading.
|
||||
const isKnownWord = (text: string, reading?: string): boolean =>
|
||||
text === '食べる' && reading === undefined;
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '食べた',
|
||||
headword: '食べる',
|
||||
reading: 'タベタ',
|
||||
partOfSpeech: PartOfSpeech.verb,
|
||||
endPos: 3,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(tokens, makeDeps({ isKnownWord }));
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
});
|
||||
|
||||
test('annotateTokens marks known words when N+1 is disabled', () => {
|
||||
const tokens = [
|
||||
makeToken({ surface: '私', headword: '私', startPos: 0, endPos: 1 }),
|
||||
@@ -1614,6 +1722,36 @@ test('annotateTokens keeps known status while clearing other annotations for sta
|
||||
}
|
||||
});
|
||||
|
||||
test('annotateTokens excludes standalone noun-suffix tokens from annotations while keeping cache-backed known status', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'さん',
|
||||
headword: 'さん',
|
||||
reading: 'サン',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '接尾',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
frequencyRank: 33,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === 'さん',
|
||||
getJlptLevel: (text) => (text === 'さん' ? 'N5' : null),
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -1930,7 +2068,8 @@ test('annotateTokens keeps known status while clearing other annotations for aru
|
||||
assert.equal(result[0]?.headword, '有る');
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.isNameMatch, false);
|
||||
// Name matches take precedence over the annotation noise filter.
|
||||
assert.equal(result[0]?.isNameMatch, true);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ const jlptLevelLookupCaches = new WeakMap<
|
||||
>();
|
||||
|
||||
export interface AnnotationStageDeps {
|
||||
isKnownWord: (text: string) => boolean;
|
||||
isKnownWord: (text: string, reading?: string) => boolean;
|
||||
knownWordMatchMode: NPlusOneMatchMode;
|
||||
getJlptLevel: (text: string) => JlptLevel | null;
|
||||
}
|
||||
@@ -661,26 +661,57 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns the token's trimmed reading only when it plausibly covers the surface
|
||||
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
|
||||
// known-word reading disambiguation and the reading fallback stay in sync if the
|
||||
// validity rule changes.
|
||||
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
|
||||
const normalizedReading = token.reading.trim();
|
||||
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizedReading;
|
||||
}
|
||||
|
||||
// Reading to disambiguate the known-word text match, or undefined when the
|
||||
// token has no reading that describes the match text: in headword mode an
|
||||
// inflected surface's reading does not match the dictionary form's reading,
|
||||
// and partial furigana readings (see isCompleteReadingForSurface) would cause
|
||||
// false negatives. Undefined falls back to text-only matching (fail-open).
|
||||
function resolveKnownWordReadingForMatch(
|
||||
token: MergedToken,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): string | undefined {
|
||||
if (knownWordMatchMode === 'headword') {
|
||||
const headwordReading = token.headwordReading?.trim();
|
||||
if (headwordReading) {
|
||||
return headwordReading;
|
||||
}
|
||||
if (token.surface !== token.headword) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveCompleteTokenReading(token);
|
||||
}
|
||||
|
||||
function computeTokenKnownStatus(
|
||||
token: MergedToken,
|
||||
isKnownWord: (text: string) => boolean,
|
||||
isKnownWord: (text: string, reading?: string) => boolean,
|
||||
knownWordMatchMode: NPlusOneMatchMode,
|
||||
): boolean {
|
||||
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
|
||||
if (token.isKnown || (matchText ? isKnownWord(matchText) : false)) {
|
||||
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
|
||||
if (token.isKnown || (matchText ? isKnownWord(matchText, matchReading) : false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedReading = token.reading.trim();
|
||||
if (!normalizedReading) {
|
||||
const fallbackReading = resolveCompleteTokenReading(token);
|
||||
if (!fallbackReading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedReading !== matchText.trim() && isKnownWord(normalizedReading);
|
||||
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
|
||||
}
|
||||
|
||||
function filterTokenFrequencyRank(
|
||||
@@ -747,7 +778,13 @@ export function annotateTokens(
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
|
||||
// A confirmed character-name match must survive the POS noise filter:
|
||||
// MeCab can tag a name like 平 as a prefix (接頭詞) depending on context,
|
||||
// which would otherwise strip the name match and its portrait.
|
||||
if (
|
||||
!prioritizedNameMatch &&
|
||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
@@ -763,8 +800,6 @@ export function annotateTokens(
|
||||
};
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
|
||||
const frequencyRank =
|
||||
frequencyEnabled && !prioritizedNameMatch
|
||||
? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
|
||||
@@ -128,6 +128,61 @@ test('drops scanning parser tokens which have no dictionary headword', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: 「…とこ戻ろ…」 — Yomitan cannot deinflect the truncated volitional 戻ろ
|
||||
// (the bare ろ rule is ichidan-only, 戻る is godan), so 戻 comes back with no headword
|
||||
// while ろ matches an unrelated term (櫓). Dropping 戻 made it a plain text node:
|
||||
// unhoverable, unannotated, and invisible to the n+1 candidate count.
|
||||
test('emits unparsed non-caption text as a token with surface headword', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[{ text: 'みんな', reading: 'みんな', headword: '皆' }],
|
||||
[{ text: 'の', reading: 'の', headword: 'の' }],
|
||||
[{ text: 'とこ', reading: 'とこ', headword: '所' }],
|
||||
[{ text: '戻', reading: '' }],
|
||||
[{ text: 'ろ', reading: 'ろ', headword: '櫓' }],
|
||||
[{ text: '…', reading: '' }],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
|
||||
[
|
||||
{ surface: 'みんな', headword: '皆' },
|
||||
{ surface: 'の', headword: 'の' },
|
||||
{ surface: 'とこ', headword: '所' },
|
||||
{ surface: '戻', headword: '戻' },
|
||||
{ surface: 'ろ', headword: '櫓' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('still drops punctuation-only and whitespace-only unparsed runs', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[{ text: '猫', reading: 'ねこ', headword: '猫' }],
|
||||
[{ text: '…', reading: '' }],
|
||||
[{ text: ' ', reading: '' }],
|
||||
[{ text: '犬', reading: 'いぬ', headword: '犬' }],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.equal(tokens?.map((token) => token.surface).join(','), '猫,犬');
|
||||
});
|
||||
|
||||
test('candidate with only unparsed tokens still yields no dictionary match', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[{ text: '戻', reading: '' }],
|
||||
[{ text: '轟', reading: '' }],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.equal(tokens, null);
|
||||
});
|
||||
|
||||
test('prefers the longest dictionary headword across merged segments', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
|
||||
@@ -151,6 +151,39 @@ function isStandaloneGrammarEndingSegment(segment: YomitanParseSegment): boolean
|
||||
);
|
||||
}
|
||||
|
||||
// Caption-style asides (SFX/speaker labels) start with a bracket and should stay
|
||||
// non-interactive; dropping punctuation-only runs also keeps the source-text gap that
|
||||
// sentence-boundary detection relies on (e.g. a dropped 「…」).
|
||||
const CAPTION_OPENING_BRACKETS = new Set([
|
||||
'(',
|
||||
'(',
|
||||
'[',
|
||||
'[',
|
||||
'{',
|
||||
'{',
|
||||
'「',
|
||||
'『',
|
||||
'【',
|
||||
'〈',
|
||||
'《',
|
||||
'≪',
|
||||
'<',
|
||||
'<',
|
||||
]);
|
||||
|
||||
function hasLookupWorthyText(text: string): boolean {
|
||||
return /[\p{L}\p{N}]/u.test(text);
|
||||
}
|
||||
|
||||
function isCaptionLikeUnparsedText(text: string): boolean {
|
||||
const firstChar = Array.from(text.trim())[0];
|
||||
return firstChar !== undefined && CAPTION_OPENING_BRACKETS.has(firstChar);
|
||||
}
|
||||
|
||||
function shouldEmitUnparsedRunAsToken(text: string): boolean {
|
||||
return hasLookupWorthyText(text) && !isCaptionLikeUnparsedText(text);
|
||||
}
|
||||
|
||||
function shouldMergeKanaContinuation(
|
||||
previousToken: MergedToken | undefined,
|
||||
continuationSurface: string,
|
||||
@@ -241,6 +274,11 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
previousToken.surface += combinedSurface;
|
||||
previousToken.reading += combinedReading;
|
||||
previousToken.endPos = end;
|
||||
} else if (shouldEmitUnparsedRunAsToken(combinedSurface)) {
|
||||
// Yomitan couldn't parse this run (e.g. 戻ろ… truncated volitional). Keep it
|
||||
// as a token with its surface as headword so it stays hoverable and counts in
|
||||
// the n+1 math — matching what the embedded Yomitan actually returns.
|
||||
pushToken(combinedSurface, combinedReading, combinedSurface, combinedStart, end);
|
||||
}
|
||||
} else {
|
||||
hasDictionaryMatch = true;
|
||||
|
||||
@@ -966,6 +966,7 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
surface: '潜み',
|
||||
reading: 'ひそみ',
|
||||
headword: '潜む',
|
||||
headwordReading: 'ひそむ',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
isNameMatch: false,
|
||||
@@ -974,6 +975,105 @@ test('requestYomitanScanTokens extracts best frequency rank from selected termsF
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens retries shorter windows when a greedy match has no exact-source headword', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profileIndex: 0,
|
||||
scanLength: 40,
|
||||
dictionaries: ['JMdict'],
|
||||
dictionaryPriorityByName: { JMdict: 0 },
|
||||
dictionaryFrequencyModeByName: {},
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [{ name: 'JMdict', enabled: true, id: 0 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens('平 (平)', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
if (!text.startsWith('平')) {
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
if (text.length >= 4) {
|
||||
// Simulates Yomitan normalization consuming punctuation/whitespace:
|
||||
// the greedy match spans 平 (平 but no headword source equals it.
|
||||
return {
|
||||
originalTextLength: 4,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '平々',
|
||||
reading: 'へいへい',
|
||||
sources: [{ originalText: '平平', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
originalTextLength: 1,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '平',
|
||||
reading: 'たいら',
|
||||
sources: [{ originalText: '平', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '平',
|
||||
reading: 'たいら',
|
||||
headword: '平',
|
||||
headwordReading: 'たいら',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isNameMatch: false,
|
||||
frequencyRank: undefined,
|
||||
},
|
||||
{
|
||||
surface: '平',
|
||||
reading: 'たいら',
|
||||
headword: '平',
|
||||
headwordReading: 'たいら',
|
||||
startPos: 3,
|
||||
endPos: 4,
|
||||
isNameMatch: false,
|
||||
frequencyRank: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens emits complete readings for kanji-kana compounds', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
@@ -1032,6 +1132,7 @@ test('requestYomitanScanTokens emits complete readings for kanji-kana compounds'
|
||||
surface: '待ち合わせてる',
|
||||
reading: 'まちあわせてる',
|
||||
headword: '待ち合わせる',
|
||||
headwordReading: 'まちあわせる',
|
||||
startPos: 0,
|
||||
endPos: 7,
|
||||
isNameMatch: false,
|
||||
@@ -1139,6 +1240,7 @@ test('requestYomitanScanTokens uses frequency from later exact-match entry when
|
||||
surface: '者',
|
||||
reading: 'もの',
|
||||
headword: '者',
|
||||
headwordReading: 'もの',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isNameMatch: false,
|
||||
@@ -1240,6 +1342,7 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
|
||||
surface: '者',
|
||||
reading: 'もの',
|
||||
headword: '者',
|
||||
headwordReading: 'もの',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
isNameMatch: false,
|
||||
@@ -1340,6 +1443,7 @@ test('requestYomitanScanTokens uses exact frequency entry when selected reading
|
||||
surface: '第二',
|
||||
reading: 'だいに',
|
||||
headword: '第二',
|
||||
headwordReading: 'だいに',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
isNameMatch: false,
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface YomitanScanToken {
|
||||
surface: string;
|
||||
reading: string;
|
||||
headword: string;
|
||||
headwordReading?: string;
|
||||
startPos: number;
|
||||
endPos: number;
|
||||
isNameMatch?: boolean;
|
||||
@@ -92,6 +93,7 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
typeof entry.surface === 'string' &&
|
||||
typeof entry.reading === 'string' &&
|
||||
typeof entry.headword === 'string' &&
|
||||
(entry.headwordReading === undefined || typeof entry.headwordReading === 'string') &&
|
||||
typeof entry.startPos === 'number' &&
|
||||
typeof entry.endPos === 'number' &&
|
||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||
@@ -1295,46 +1297,69 @@ ${YOMITAN_SCANNING_HELPERS}
|
||||
const text = ${JSON.stringify(text)};
|
||||
const details = {matchType: "exact", deinflect: true};
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const codePoint = text.codePointAt(i);
|
||||
async function findTokenAt(position, windowLength) {
|
||||
const codePoint = text.codePointAt(position);
|
||||
const character = String.fromCodePoint(codePoint);
|
||||
const substring = text.substring(i, i + ${scanLength});
|
||||
const substring = text.substring(position, position + windowLength);
|
||||
const result = await invoke("termsFind", { text: substring, details, optionsContext: { index: ${profileIndex} } });
|
||||
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
|
||||
const originalTextLength = typeof result?.originalTextLength === "number" ? result.originalTextLength : 0;
|
||||
if (dictionaryEntries.length > 0 && originalTextLength > 0 && (originalTextLength !== character.length || isCodePointJapanese(codePoint))) {
|
||||
const source = substring.substring(0, originalTextLength);
|
||||
const preferredHeadword = getPreferredHeadword(
|
||||
dictionaryEntries,
|
||||
source,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (preferredHeadword && typeof preferredHeadword.term === "string") {
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||
headword: preferredHeadword.term,
|
||||
startPos: i,
|
||||
endPos: i + originalTextLength,
|
||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||
frequencyRank:
|
||||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||||
: undefined,
|
||||
};
|
||||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||||
}
|
||||
tokens.push(tokenPayload);
|
||||
i += originalTextLength;
|
||||
continue;
|
||||
}
|
||||
if (dictionaryEntries.length === 0 || originalTextLength <= 0 || (originalTextLength === character.length && !isCodePointJapanese(codePoint))) {
|
||||
return { token: null, matchedLength: 0 };
|
||||
}
|
||||
i += character.length;
|
||||
const source = substring.substring(0, originalTextLength);
|
||||
const preferredHeadword = getPreferredHeadword(
|
||||
dictionaryEntries,
|
||||
source,
|
||||
dictionaryPriorityByName,
|
||||
dictionaryFrequencyModeByName
|
||||
);
|
||||
if (!preferredHeadword || typeof preferredHeadword.term !== "string") {
|
||||
return { token: null, matchedLength: originalTextLength };
|
||||
}
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map(getSegmentReadingContribution).join(""),
|
||||
headword: preferredHeadword.term,
|
||||
headwordReading: reading || undefined,
|
||||
startPos: position,
|
||||
endPos: position + originalTextLength,
|
||||
isNameMatch: includeNameMatchMetadata && preferredHeadword.isNameMatch === true,
|
||||
frequencyRank:
|
||||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||||
: undefined,
|
||||
};
|
||||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||||
}
|
||||
return { token: tokenPayload, matchedLength: originalTextLength };
|
||||
}
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
let attempt = await findTokenAt(i, ${scanLength});
|
||||
// Yomitan text normalization can consume characters (whitespace,
|
||||
// punctuation) beyond the matched term, leaving no headword whose
|
||||
// source equals the consumed text. Retry with shorter windows so a
|
||||
// valid prefix term (e.g. a character name before a paren) still
|
||||
// tokenizes instead of the position being skipped.
|
||||
let retryLength = Math.min(attempt.matchedLength, ${scanLength}) - 1;
|
||||
while (!attempt.token && retryLength >= 1) {
|
||||
const retry = await findTokenAt(i, retryLength);
|
||||
if (retry.token) {
|
||||
attempt = retry;
|
||||
break;
|
||||
}
|
||||
retryLength = Math.min(retryLength - 1, retry.matchedLength - 1);
|
||||
}
|
||||
if (attempt.token) {
|
||||
tokens.push(attempt.token);
|
||||
i += attempt.matchedLength;
|
||||
continue;
|
||||
}
|
||||
i += String.fromCodePoint(text.codePointAt(i)).length;
|
||||
}
|
||||
return tokens;
|
||||
})();
|
||||
|
||||
@@ -37,6 +37,7 @@ import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
@@ -2324,6 +2325,9 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
||||
waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
handleOverlayModalClosed: (modal) => overlayModalRuntime.handleOverlayModalClosed(modal),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
overlayModalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
})(),
|
||||
@@ -2369,6 +2373,11 @@ const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
getNameMatchImagesEnabled: () => getResolvedConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||
getCollapsibleSectionOpenState: (section) =>
|
||||
getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||
tokenizeJapaneseName: async (text) => (await appState.mecabTokenizer?.tokenize(text)) ?? null,
|
||||
getJapaneseNameTokenizerAvailable: () => {
|
||||
const status = appState.mecabTokenizer?.getStatus();
|
||||
return status?.available === true && status.enabled === true;
|
||||
},
|
||||
now: () => Date.now(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
@@ -4066,6 +4075,14 @@ const statsStartupRuntime = {
|
||||
}
|
||||
},
|
||||
} as const;
|
||||
const ensureBackgroundStatsServer = createEnsureBackgroundStatsServerHandler({
|
||||
isStatsAutoStartEnabled: () => getResolvedConfig().stats.autoStartServer,
|
||||
isImmersionTrackingEnabled: () => getResolvedConfig().immersionTracking?.enabled !== false,
|
||||
ensureBackgroundStatsServerStarted: () =>
|
||||
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
@@ -4635,7 +4652,7 @@ const {
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
isKnownWord: (text) => Boolean(appState.ankiIntegration?.isKnownWord(text)),
|
||||
isKnownWord: (text, reading) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading)),
|
||||
recordLookup: (hit) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordLookup(hit);
|
||||
@@ -5962,6 +5979,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
);
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
ensureBackgroundStatsServer: () => ensureBackgroundStatsServer(),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
createCharacterDictionaryManualSelectionStore,
|
||||
} from './character-dictionary-runtime/manual-selection';
|
||||
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||
import { resolveJapaneseNameSplits } from './character-dictionary-runtime/name-split-resolver';
|
||||
import type {
|
||||
AniListMediaCandidate,
|
||||
CharacterDictionaryBuildResult,
|
||||
@@ -175,11 +176,19 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
userDataPath: deps.userDataPath,
|
||||
});
|
||||
|
||||
const shouldRefreshCachedSnapshot = (snapshot: CharacterDictionarySnapshot): boolean => {
|
||||
if (deps.getNameMatchImagesEnabled?.() !== true) {
|
||||
return false;
|
||||
const isNameSplitTokenizerAvailable = (): boolean =>
|
||||
typeof deps.tokenizeJapaneseName === 'function' &&
|
||||
deps.getJapaneseNameTokenizerAvailable?.() === true;
|
||||
|
||||
const getCachedSnapshotRefreshReason = (snapshot: CharacterDictionarySnapshot): string | null => {
|
||||
if (deps.getNameMatchImagesEnabled?.() === true && !snapshotHasCharacterNameImages(snapshot)) {
|
||||
return 'missing cached character images';
|
||||
}
|
||||
return !snapshotHasCharacterNameImages(snapshot);
|
||||
// Heuristic name splits are upgraded once MeCab becomes available.
|
||||
if (snapshot.nameSplitSource !== 'mecab' && isNameSplitTokenizerAvailable()) {
|
||||
return 'name splits predate MeCab availability';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const createAniListRequestSlot = (): (() => Promise<void>) => {
|
||||
@@ -323,7 +332,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
): Promise<CharacterDictionarySnapshotResult> => {
|
||||
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
||||
const cachedSnapshot = readSnapshot(snapshotPath);
|
||||
if (cachedSnapshot && !shouldRefreshCachedSnapshot(cachedSnapshot)) {
|
||||
const refreshReason = cachedSnapshot ? getCachedSnapshotRefreshReason(cachedSnapshot) : null;
|
||||
if (cachedSnapshot && refreshReason === null) {
|
||||
deps.logInfo?.(`[dictionary] snapshot hit for AniList ${mediaId}`);
|
||||
return {
|
||||
mediaId: cachedSnapshot.mediaId,
|
||||
@@ -334,9 +344,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
};
|
||||
}
|
||||
if (cachedSnapshot) {
|
||||
deps.logInfo?.(
|
||||
`[dictionary] snapshot stale for AniList ${mediaId}: missing cached character images`,
|
||||
);
|
||||
deps.logInfo?.(`[dictionary] snapshot stale for AniList ${mediaId}: ${refreshReason}`);
|
||||
}
|
||||
|
||||
progress?.onGenerating?.({
|
||||
@@ -399,6 +407,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
}
|
||||
}
|
||||
|
||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
||||
? await resolveJapaneseNameSplits(characters, deps.tokenizeJapaneseName!, deps.logWarn)
|
||||
: undefined;
|
||||
const nameSplitSource =
|
||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
||||
|
||||
const snapshot = buildSnapshotFromCharacters(
|
||||
mediaId,
|
||||
fetchedMediaTitle || mediaTitleHint || `AniList ${mediaId}`,
|
||||
@@ -407,6 +422,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
imagesByVaId,
|
||||
deps.now(),
|
||||
getCollapsibleSectionOpenState,
|
||||
resolvedNameSplits,
|
||||
nameSplitSource,
|
||||
);
|
||||
writeSnapshot(snapshotPath, snapshot);
|
||||
deps.logInfo?.(
|
||||
|
||||
@@ -36,7 +36,20 @@ test('writeSnapshot persists and readSnapshot restores current-format snapshots'
|
||||
|
||||
writeSnapshot(snapshotPath, snapshot);
|
||||
|
||||
assert.deepEqual(readSnapshot(snapshotPath), snapshot);
|
||||
assert.deepEqual(readSnapshot(snapshotPath), { ...snapshot, nameSplitSource: 'heuristic' });
|
||||
});
|
||||
|
||||
test('readSnapshot preserves the mecab name-split source and defaults missing values to heuristic', () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshotPath = getSnapshotPath(outputDir, 130298);
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
...createSnapshot(),
|
||||
nameSplitSource: 'mecab',
|
||||
};
|
||||
|
||||
writeSnapshot(snapshotPath, snapshot);
|
||||
|
||||
assert.equal(readSnapshot(snapshotPath)?.nameSplitSource, 'mecab');
|
||||
});
|
||||
|
||||
test('readSnapshot ignores snapshots written with an older format version', () => {
|
||||
|
||||
@@ -141,6 +141,7 @@ export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot
|
||||
mediaTitle: parsed.mediaTitle,
|
||||
entryCount: parsed.entryCount,
|
||||
updatedAt: parsed.updatedAt,
|
||||
nameSplitSource: parsed.nameSplitSource === 'mecab' ? 'mecab' : 'heuristic',
|
||||
termEntries: parsed.termEntries as CharacterDictionaryTermEntry[],
|
||||
images: parsed.images as CharacterDictionarySnapshotImage[],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||
export const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||
export const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 18;
|
||||
export const CHARACTER_DICTIONARY_FORMAT_VERSION = 19;
|
||||
export const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||
|
||||
export const HONORIFIC_SUFFIXES = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HONORIFIC_SUFFIXES } from './constants';
|
||||
import type { JapaneseNameParts, NameReadings } from './types';
|
||||
import type { JapaneseNameParts, NameReadings, ResolvedNameSplits } from './types';
|
||||
|
||||
export function hasKanaOnly(value: string): boolean {
|
||||
return /^[\u3040-\u309f\u30a0-\u30ffー]+$/.test(value);
|
||||
@@ -262,7 +262,7 @@ export function buildReadingFromRomanized(value: string): string {
|
||||
return katakana ? katakanaToHiragana(katakana) : '';
|
||||
}
|
||||
|
||||
function buildReadingFromHint(value: string): string {
|
||||
export function buildReadingFromHint(value: string): string {
|
||||
return buildReading(value) || buildReadingFromRomanized(value);
|
||||
}
|
||||
|
||||
@@ -273,20 +273,22 @@ function scoreJapaneseNamePartLength(length: number): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inferJapaneseNameSplitIndex(
|
||||
// Ranks every possible family/given boundary. Reading-length ratios cannot
|
||||
// always identify the true boundary (あずま can be one kanji or two), so
|
||||
// callers may take the top candidates rather than trusting only the best.
|
||||
function inferJapaneseNameSplitIndices(
|
||||
nameOriginal: string,
|
||||
firstNameHint: string,
|
||||
lastNameHint: string,
|
||||
): number | null {
|
||||
): number[] {
|
||||
const chars = [...nameOriginal];
|
||||
if (chars.length < 2) return null;
|
||||
if (chars.length < 2) return [];
|
||||
|
||||
const familyHintLength = [...buildReadingFromHint(lastNameHint)].length;
|
||||
const givenHintLength = [...buildReadingFromHint(firstNameHint)].length;
|
||||
const totalHintLength = familyHintLength + givenHintLength;
|
||||
const defaultBoundary = Math.round(chars.length / 2);
|
||||
let bestIndex: number | null = null;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
const scored: Array<{ index: number; score: number }> = [];
|
||||
|
||||
for (let index = 1; index < chars.length; index += 1) {
|
||||
const familyLength = index;
|
||||
@@ -309,13 +311,10 @@ function inferJapaneseNameSplitIndex(
|
||||
score += 0.25;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
}
|
||||
scored.push({ index, score });
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
return scored.sort((left, right) => right.score - left.score).map((entry) => entry.index);
|
||||
}
|
||||
|
||||
export function addRomanizedKanaAliases(values: Iterable<string>): string[] {
|
||||
@@ -335,6 +334,7 @@ export function splitJapaneseName(
|
||||
nameOriginal: string,
|
||||
firstNameHint?: string,
|
||||
lastNameHint?: string,
|
||||
resolvedSplits?: ResolvedNameSplits,
|
||||
): JapaneseNameParts {
|
||||
const trimmed = nameOriginal.trim();
|
||||
if (!trimmed) {
|
||||
@@ -377,6 +377,22 @@ export function splitJapaneseName(
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSplit = resolvedSplits?.get(trimmed);
|
||||
if (
|
||||
resolvedSplit &&
|
||||
resolvedSplit.family &&
|
||||
resolvedSplit.given &&
|
||||
`${resolvedSplit.family}${resolvedSplit.given}` === trimmed
|
||||
) {
|
||||
return {
|
||||
hasSpace: true,
|
||||
original: trimmed,
|
||||
combined: trimmed,
|
||||
family: resolvedSplit.family,
|
||||
given: resolvedSplit.given,
|
||||
};
|
||||
}
|
||||
|
||||
const hintedFirst = firstNameHint?.trim() || '';
|
||||
const hintedLast = lastNameHint?.trim() || '';
|
||||
if (hintedFirst && hintedLast) {
|
||||
@@ -404,7 +420,7 @@ export function splitJapaneseName(
|
||||
}
|
||||
|
||||
if (hintedFirst && hintedLast && containsKanji(trimmed)) {
|
||||
const splitIndex = inferJapaneseNameSplitIndex(trimmed, hintedFirst, hintedLast);
|
||||
const splitIndex = inferJapaneseNameSplitIndices(trimmed, hintedFirst, hintedLast)[0] ?? null;
|
||||
if (splitIndex != null) {
|
||||
const chars = [...trimmed];
|
||||
const family = chars.slice(0, splitIndex).join('');
|
||||
@@ -430,11 +446,62 @@ export function splitJapaneseName(
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_INFERRED_SPLIT_CANDIDATES = 2;
|
||||
|
||||
// Returns the possible family/given splits, best first. Only a boundary
|
||||
// guessed by the length heuristic is ambiguous (あずま can be one kanji or
|
||||
// two), so only that path yields a runner-up candidate; explicit separators,
|
||||
// resolved (MeCab) splits, and exact hint matches are trusted as-is.
|
||||
export function splitJapaneseNameCandidates(
|
||||
nameOriginal: string,
|
||||
firstNameHint?: string,
|
||||
lastNameHint?: string,
|
||||
resolvedSplits?: ResolvedNameSplits,
|
||||
): JapaneseNameParts[] {
|
||||
const primary = splitJapaneseName(nameOriginal, firstNameHint, lastNameHint, resolvedSplits);
|
||||
if (!primary.family || !primary.given) {
|
||||
return [primary];
|
||||
}
|
||||
|
||||
const trimmed = nameOriginal.trim();
|
||||
if (primary.combined !== trimmed) {
|
||||
return [primary];
|
||||
}
|
||||
const resolvedSplit = resolvedSplits?.get(trimmed);
|
||||
if (resolvedSplit && `${resolvedSplit.family}${resolvedSplit.given}` === trimmed) {
|
||||
return [primary];
|
||||
}
|
||||
const hintedFirst = firstNameHint?.trim() || '';
|
||||
const hintedLast = lastNameHint?.trim() || '';
|
||||
if (`${hintedLast}${hintedFirst}` === trimmed || `${hintedFirst}${hintedLast}` === trimmed) {
|
||||
return [primary];
|
||||
}
|
||||
|
||||
const candidates = [primary];
|
||||
const chars = [...trimmed];
|
||||
const splitIndices = inferJapaneseNameSplitIndices(trimmed, hintedFirst, hintedLast);
|
||||
for (const splitIndex of splitIndices.slice(1, MAX_INFERRED_SPLIT_CANDIDATES)) {
|
||||
const family = chars.slice(0, splitIndex).join('');
|
||||
const given = chars.slice(splitIndex).join('');
|
||||
if (family && given) {
|
||||
candidates.push({
|
||||
hasSpace: true,
|
||||
original: trimmed,
|
||||
combined: trimmed,
|
||||
family,
|
||||
given,
|
||||
});
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function generateNameReadings(
|
||||
nameOriginal: string,
|
||||
romanizedName: string,
|
||||
firstNameHint?: string,
|
||||
lastNameHint?: string,
|
||||
resolvedSplits?: ResolvedNameSplits,
|
||||
): NameReadings {
|
||||
const trimmed = nameOriginal.trim();
|
||||
if (!trimmed) {
|
||||
@@ -447,7 +514,7 @@ export function generateNameReadings(
|
||||
};
|
||||
}
|
||||
|
||||
const nameParts = splitJapaneseName(trimmed, firstNameHint, lastNameHint);
|
||||
const nameParts = splitJapaneseName(trimmed, firstNameHint, lastNameHint, resolvedSplits);
|
||||
if (!nameParts.hasSpace || !nameParts.family || !nameParts.given) {
|
||||
const full = containsKanji(trimmed)
|
||||
? buildReadingFromRomanized(romanizedName)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveJapaneseNameSplits } from './name-split-resolver';
|
||||
import { splitJapaneseName, splitJapaneseNameCandidates } from './name-reading';
|
||||
import { buildNameTerms } from './term-building';
|
||||
import type { CharacterRecord, NameSplitToken } from './types';
|
||||
|
||||
function characterRecord(overrides: Partial<CharacterRecord>): CharacterRecord {
|
||||
return {
|
||||
id: 302626,
|
||||
role: 'main',
|
||||
firstNameHint: 'Shino',
|
||||
fullName: 'Shino Azuma',
|
||||
lastNameHint: 'Azuma',
|
||||
nativeName: '東紫乃',
|
||||
alternativeNames: [],
|
||||
bloodType: '',
|
||||
birthday: null,
|
||||
description: '',
|
||||
imageUrl: null,
|
||||
age: '',
|
||||
sex: '',
|
||||
voiceActors: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function personNameToken(word: string, role: '姓' | '名', katakanaReading: string): NameSplitToken {
|
||||
return { word, pos1: '名詞', pos2: '固有名詞', pos3: '人名', pos4: role, katakanaReading };
|
||||
}
|
||||
|
||||
function tokenizerFor(
|
||||
tokensByName: Record<string, NameSplitToken[]>,
|
||||
): (text: string) => Promise<NameSplitToken[] | null> {
|
||||
return async (text) => tokensByName[text] ?? null;
|
||||
}
|
||||
|
||||
test('resolveJapaneseNameSplits splits a single-kanji surname via person-name POS tags', async () => {
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[characterRecord({})],
|
||||
tokenizerFor({
|
||||
東紫乃: [personNameToken('東', '姓', 'アズマ'), personNameToken('紫乃', '名', 'シノ')],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
||||
});
|
||||
|
||||
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[
|
||||
characterRecord({
|
||||
nativeName: '渡辺真奈美',
|
||||
fullName: 'Manami Watanabe',
|
||||
firstNameHint: 'Manami',
|
||||
lastNameHint: 'Watanabe',
|
||||
}),
|
||||
],
|
||||
tokenizerFor({
|
||||
渡辺真奈美: [
|
||||
personNameToken('渡辺', '姓', 'ワタナベ'),
|
||||
personNameToken('真奈美', '名', 'マナミ'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
||||
});
|
||||
|
||||
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[
|
||||
characterRecord({
|
||||
nativeName: '鈴木みゆ',
|
||||
fullName: 'Miyu Suzuki',
|
||||
firstNameHint: 'Miyu',
|
||||
lastNameHint: 'Suzuki',
|
||||
}),
|
||||
],
|
||||
tokenizerFor({
|
||||
鈴木みゆ: [
|
||||
personNameToken('鈴木', '姓', 'スズキ'),
|
||||
{ word: 'みゆ', pos1: '名詞', pos2: '一般', pos3: '*', pos4: '*', katakanaReading: 'ミユ' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
||||
});
|
||||
|
||||
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[characterRecord({})],
|
||||
tokenizerFor({
|
||||
東紫乃: [personNameToken('東', '姓', 'アズマ'), personNameToken('乃', '名', 'ノ')],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(splits.size, 0);
|
||||
});
|
||||
|
||||
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[
|
||||
characterRecord({
|
||||
nativeName: '担任',
|
||||
fullName: 'Tannin',
|
||||
firstNameHint: 'Tannin',
|
||||
lastNameHint: '',
|
||||
}),
|
||||
],
|
||||
tokenizerFor({
|
||||
担任: [
|
||||
{ word: '担', pos1: '名詞', pos2: '一般', pos3: '*', pos4: '*', katakanaReading: 'タン' },
|
||||
{ word: '任', pos1: '名詞', pos2: '一般', pos3: '*', pos4: '*', katakanaReading: 'ニン' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(splits.size, 0);
|
||||
});
|
||||
|
||||
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
||||
const warnings: string[] = [];
|
||||
const splits = await resolveJapaneseNameSplits(
|
||||
[characterRecord({})],
|
||||
async () => {
|
||||
throw new Error('mecab unavailable');
|
||||
},
|
||||
(message) => warnings.push(message),
|
||||
);
|
||||
|
||||
assert.equal(splits.size, 0);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /mecab unavailable/);
|
||||
});
|
||||
|
||||
test('splitJapaneseName prefers a resolved split over hint-length inference', () => {
|
||||
const resolved = new Map([['東紫乃', { family: '東', given: '紫乃' }]]);
|
||||
|
||||
const withResolved = splitJapaneseName('東紫乃', 'Shino', 'Azuma', resolved);
|
||||
assert.equal(withResolved.family, '東');
|
||||
assert.equal(withResolved.given, '紫乃');
|
||||
|
||||
const withoutResolved = splitJapaneseName('東紫乃', 'Shino', 'Azuma');
|
||||
assert.notEqual(withoutResolved.family, '東');
|
||||
});
|
||||
|
||||
test('splitJapaneseNameCandidates emits the runner-up boundary only for inferred splits', () => {
|
||||
const inferred = splitJapaneseNameCandidates('東紫乃', 'Shino', 'Azuma');
|
||||
assert.equal(inferred.length, 2);
|
||||
assert.deepEqual(
|
||||
inferred.map((parts) => `${parts.family}|${parts.given}`).sort(),
|
||||
['東紫|乃', '東|紫乃'].sort(),
|
||||
);
|
||||
|
||||
const resolved = new Map([['東紫乃', { family: '東', given: '紫乃' }]]);
|
||||
const trusted = splitJapaneseNameCandidates('東紫乃', 'Shino', 'Azuma', resolved);
|
||||
assert.equal(trusted.length, 1);
|
||||
assert.equal(trusted[0]!.family, '東');
|
||||
|
||||
const spaced = splitJapaneseNameCandidates('須々木 心一', 'Shinichi', 'Susuki');
|
||||
assert.equal(spaced.length, 1);
|
||||
});
|
||||
|
||||
test('buildNameTerms without resolved splits still emits both candidate surnames', () => {
|
||||
const terms = buildNameTerms(characterRecord({}));
|
||||
|
||||
assert.ok(terms.includes('東'));
|
||||
assert.ok(terms.includes('紫乃'));
|
||||
assert.ok(terms.includes('東紫'));
|
||||
assert.ok(terms.includes('乃'));
|
||||
});
|
||||
|
||||
test('buildNameTerms emits surname and given-name terms from resolved splits', () => {
|
||||
const resolved = new Map([['渡辺真奈美', { family: '渡辺', given: '真奈美' }]]);
|
||||
const terms = buildNameTerms(
|
||||
characterRecord({
|
||||
nativeName: '渡辺真奈美',
|
||||
fullName: 'Manami Watanabe',
|
||||
firstNameHint: 'Manami',
|
||||
lastNameHint: 'Watanabe',
|
||||
}),
|
||||
resolved,
|
||||
);
|
||||
|
||||
assert.ok(terms.includes('渡辺'));
|
||||
assert.ok(terms.includes('真奈美'));
|
||||
assert.ok(terms.includes('渡辺真奈美'));
|
||||
assert.ok(!terms.includes('渡辺真'));
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { buildReading, buildReadingFromHint } from './name-reading';
|
||||
import { expandRawNameVariants, isJapaneseNameSplitCandidate } from './term-building';
|
||||
import type {
|
||||
CharacterRecord,
|
||||
NameSplitToken,
|
||||
NameSplitTokenizer,
|
||||
ResolvedNameSplit,
|
||||
} from './types';
|
||||
|
||||
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
||||
|
||||
function joinSurfaces(tokens: NameSplitToken[]): string {
|
||||
return tokens.map((token) => token.word).join('');
|
||||
}
|
||||
|
||||
// MeCab tags dictionary-known person names as 名詞,固有名詞,人名,姓|名. A split is
|
||||
// trusted only when every leading token is 姓 and every remaining token is 名.
|
||||
function splitIndexFromPersonNamePos(tokens: NameSplitToken[]): number | null {
|
||||
let familyEnd = 0;
|
||||
while (
|
||||
familyEnd < tokens.length &&
|
||||
tokens[familyEnd]!.pos3 === '人名' &&
|
||||
tokens[familyEnd]!.pos4 === '姓'
|
||||
) {
|
||||
familyEnd += 1;
|
||||
}
|
||||
if (familyEnd === 0 || familyEnd >= tokens.length) {
|
||||
return null;
|
||||
}
|
||||
for (let index = familyEnd; index < tokens.length; index += 1) {
|
||||
if (tokens[index]!.pos3 !== '人名' || tokens[index]!.pos4 !== '名') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return familyEnd;
|
||||
}
|
||||
|
||||
function splitIndexFromHintReadings(
|
||||
tokens: NameSplitToken[],
|
||||
familyHintReading: string,
|
||||
givenHintReading: string,
|
||||
): number | null {
|
||||
if (!familyHintReading && !givenHintReading) {
|
||||
return null;
|
||||
}
|
||||
const readings = tokens.map((token) => buildReading(token.katakanaReading || ''));
|
||||
let matchedIndex: number | null = null;
|
||||
for (let index = 1; index < tokens.length; index += 1) {
|
||||
const familyReadings = readings.slice(0, index);
|
||||
const givenReadings = readings.slice(index);
|
||||
const familyMatches =
|
||||
!!familyHintReading &&
|
||||
familyReadings.every((reading) => reading.length > 0) &&
|
||||
familyReadings.join('') === familyHintReading;
|
||||
const givenMatches =
|
||||
!!givenHintReading &&
|
||||
givenReadings.every((reading) => reading.length > 0) &&
|
||||
givenReadings.join('') === givenHintReading;
|
||||
if (!familyMatches && !givenMatches) {
|
||||
continue;
|
||||
}
|
||||
if (matchedIndex !== null && matchedIndex !== index) {
|
||||
return null;
|
||||
}
|
||||
matchedIndex = index;
|
||||
}
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
function collectSplitCandidateNames(character: CharacterRecord): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
||||
for (const rawName of rawNames) {
|
||||
for (const name of expandRawNameVariants(rawName)) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || NAME_SEPARATOR_PATTERN.test(trimmed)) continue;
|
||||
if (!isJapaneseNameSplitCandidate(trimmed)) continue;
|
||||
if ([...trimmed].length < 2) continue;
|
||||
candidates.add(trimmed);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
export async function resolveJapaneseNameSplits(
|
||||
characters: CharacterRecord[],
|
||||
tokenize: NameSplitTokenizer,
|
||||
logWarn?: (message: string) => void,
|
||||
): Promise<Map<string, ResolvedNameSplit>> {
|
||||
const splits = new Map<string, ResolvedNameSplit>();
|
||||
for (const character of characters) {
|
||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||
const givenHintReading = buildReadingFromHint(character.firstNameHint?.trim() || '');
|
||||
for (const name of collectSplitCandidateNames(character)) {
|
||||
if (splits.has(name)) continue;
|
||||
let tokens: NameSplitToken[] | null = null;
|
||||
try {
|
||||
tokens = await tokenize(name);
|
||||
} catch (err) {
|
||||
logWarn?.(
|
||||
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
||||
const splitIndex =
|
||||
splitIndexFromPersonNamePos(tokens) ??
|
||||
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
||||
if (splitIndex === null) continue;
|
||||
const family = joinSurfaces(tokens.slice(0, splitIndex));
|
||||
const given = joinSurfaces(tokens.slice(splitIndex));
|
||||
if (family && given) {
|
||||
splits.set(name, { family, given });
|
||||
}
|
||||
}
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
@@ -121,6 +121,175 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
let characterPageRequests = 0;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === GRAPHQL_URL) {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { query?: string };
|
||||
if (body.query?.includes('characters(page: $page')) {
|
||||
characterPageRequests += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
Media: {
|
||||
title: { english: 'The Eminence in Shadow' },
|
||||
characters: {
|
||||
pageInfo: { hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
role: 'SUPPORTING',
|
||||
node: {
|
||||
id: 123,
|
||||
description: 'Alexia Midgar.',
|
||||
image: { large: null, medium: null },
|
||||
name: {
|
||||
first: 'Taro',
|
||||
last: 'Yamada',
|
||||
full: 'Taro Yamada',
|
||||
native: '山田太郎',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
let tokenizerCalls = 0;
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => {
|
||||
tokenizerCalls += 1;
|
||||
return null;
|
||||
},
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
|
||||
|
||||
const retriedResult = await runtime.generateForCurrentMedia();
|
||||
assert.equal(retriedResult.fromCache, false);
|
||||
assert.equal(characterPageRequests, 2);
|
||||
assert.equal(tokenizerCalls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'mecab',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps heuristic-split snapshots while MeCab is unavailable', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
...createSnapshotWithoutImages(),
|
||||
nameSplitSource: 'heuristic',
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||
guessAnilistMediaInfo: async () => ({
|
||||
title: 'The Eminence in Shadow',
|
||||
season: null,
|
||||
episode: 5,
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => null,
|
||||
getJapaneseNameTokenizerAvailable: () => false,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('generateForCurrentMedia keeps same-version snapshots without images when inline images are disabled', async () => {
|
||||
const userDataPath = makeTempDir();
|
||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionaryTermEntry,
|
||||
CharacterRecord,
|
||||
NameSplitSource,
|
||||
ResolvedNameSplits,
|
||||
} from './types';
|
||||
|
||||
export function buildSnapshotImagePath(mediaId: number, charId: number, ext: string): string {
|
||||
@@ -34,6 +36,8 @@ export function buildSnapshotFromCharacters(
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean,
|
||||
resolvedNameSplits?: ResolvedNameSplits,
|
||||
nameSplitSource: NameSplitSource = 'heuristic',
|
||||
): CharacterDictionarySnapshot {
|
||||
const termEntries: CharacterDictionaryTermEntry[] = [];
|
||||
|
||||
@@ -45,7 +49,7 @@ export function buildSnapshotFromCharacters(
|
||||
const vaImg = imagesByVaId.get(va.id);
|
||||
if (vaImg) vaImagePaths.set(va.id, vaImg.path);
|
||||
}
|
||||
const candidateTerms = buildNameTerms(character);
|
||||
const candidateTerms = buildNameTerms(character, resolvedNameSplits);
|
||||
const glossary = createDefinitionGlossary(
|
||||
character,
|
||||
mediaId,
|
||||
@@ -59,12 +63,14 @@ export function buildSnapshotFromCharacters(
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
character.lastNameHint,
|
||||
resolvedNameSplits,
|
||||
);
|
||||
const readings = generateNameReadings(
|
||||
character.nativeName,
|
||||
character.fullName,
|
||||
character.firstNameHint,
|
||||
character.lastNameHint,
|
||||
resolvedNameSplits,
|
||||
);
|
||||
for (const term of candidateTerms) {
|
||||
if (seenTerms.has(term)) continue;
|
||||
@@ -84,6 +90,7 @@ export function buildSnapshotFromCharacters(
|
||||
mediaTitle,
|
||||
entryCount: termEntries.length,
|
||||
updatedAt,
|
||||
nameSplitSource,
|
||||
termEntries,
|
||||
images: [...imagesByCharacterId.values(), ...imagesByVaId.values()],
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasKanaOnly,
|
||||
isRomanizedName,
|
||||
splitJapaneseName,
|
||||
splitJapaneseNameCandidates,
|
||||
} from './name-reading';
|
||||
import type {
|
||||
CharacterDictionaryGlossaryEntry,
|
||||
@@ -15,9 +16,10 @@ import type {
|
||||
CharacterRecord,
|
||||
JapaneseNameParts,
|
||||
NameReadings,
|
||||
ResolvedNameSplits,
|
||||
} from './types';
|
||||
|
||||
function expandRawNameVariants(rawName: string): string[] {
|
||||
export function expandRawNameVariants(rawName: string): string[] {
|
||||
const trimmed = rawName.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
@@ -40,26 +42,41 @@ function expandRawNameVariants(rawName: string): string[] {
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function isJapaneseNameSplitCandidate(name: string): boolean {
|
||||
export function isJapaneseNameSplitCandidate(name: string): boolean {
|
||||
const compact = name.replace(/[\s\u3000・・·•]/g, '');
|
||||
return (
|
||||
containsKanji(compact) && /^[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff々〆ヵヶー]+$/.test(compact)
|
||||
);
|
||||
}
|
||||
|
||||
function addJapaneseNameParts(character: CharacterRecord, name: string, terms: Set<string>): void {
|
||||
function addJapaneseNameParts(
|
||||
character: CharacterRecord,
|
||||
name: string,
|
||||
terms: Set<string>,
|
||||
resolvedSplits?: ResolvedNameSplits,
|
||||
): void {
|
||||
if (!isJapaneseNameSplitCandidate(name)) return;
|
||||
|
||||
const nameParts = splitJapaneseName(name, character.firstNameHint, character.lastNameHint);
|
||||
if (nameParts.family) {
|
||||
terms.add(nameParts.family);
|
||||
}
|
||||
if (nameParts.given) {
|
||||
terms.add(nameParts.given);
|
||||
const candidates = splitJapaneseNameCandidates(
|
||||
name,
|
||||
character.firstNameHint,
|
||||
character.lastNameHint,
|
||||
resolvedSplits,
|
||||
);
|
||||
for (const nameParts of candidates) {
|
||||
if (nameParts.family) {
|
||||
terms.add(nameParts.family);
|
||||
}
|
||||
if (nameParts.given) {
|
||||
terms.add(nameParts.given);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
export function buildNameTerms(
|
||||
character: CharacterRecord,
|
||||
resolvedSplits?: ResolvedNameSplits,
|
||||
): string[] {
|
||||
const base = new Set<string>();
|
||||
const romanizedBase = new Set<string>();
|
||||
const rawNames = [character.nativeName, character.fullName, ...character.alternativeNames];
|
||||
@@ -95,7 +112,7 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
}
|
||||
|
||||
if (target === base) {
|
||||
addJapaneseNameParts(character, name, base);
|
||||
addJapaneseNameParts(character, name, base, resolvedSplits);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +125,7 @@ export function buildNameTerms(character: CharacterRecord): string[] {
|
||||
character.nativeName,
|
||||
character.firstNameHint,
|
||||
character.lastNameHint,
|
||||
resolvedSplits,
|
||||
);
|
||||
if (nativeParts.family) {
|
||||
base.add(nativeParts.family);
|
||||
|
||||
@@ -31,6 +31,26 @@ export type JapaneseNameParts = {
|
||||
given: string | null;
|
||||
};
|
||||
|
||||
export type ResolvedNameSplit = {
|
||||
family: string;
|
||||
given: string;
|
||||
};
|
||||
|
||||
export type ResolvedNameSplits = ReadonlyMap<string, ResolvedNameSplit>;
|
||||
|
||||
export type NameSplitToken = {
|
||||
word: string;
|
||||
pos1?: string;
|
||||
pos2?: string;
|
||||
pos3?: string;
|
||||
pos4?: string;
|
||||
katakanaReading?: string;
|
||||
};
|
||||
|
||||
export type NameSplitTokenizer = (text: string) => Promise<NameSplitToken[] | null>;
|
||||
|
||||
export type NameSplitSource = 'mecab' | 'heuristic';
|
||||
|
||||
export type NameReadings = {
|
||||
hasSpace: boolean;
|
||||
original: string;
|
||||
@@ -45,6 +65,7 @@ export type CharacterDictionarySnapshot = {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
updatedAt: number;
|
||||
nameSplitSource?: NameSplitSource;
|
||||
termEntries: CharacterDictionaryTermEntry[];
|
||||
images: CharacterDictionarySnapshotImage[];
|
||||
};
|
||||
@@ -152,6 +173,8 @@ export interface CharacterDictionaryRuntimeDeps {
|
||||
getCollapsibleSectionOpenState?: (
|
||||
section: AnilistCharacterDictionaryCollapsibleSectionKey,
|
||||
) => boolean;
|
||||
tokenizeJapaneseName?: NameSplitTokenizer;
|
||||
getJapaneseNameTokenizerAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
export type ResolvedAniListMedia = {
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandRuntimeServiceDepsParams['app']['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
|
||||
app: {
|
||||
stop: context.stopApp,
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
ensureBackgroundStatsServer: context.ensureBackgroundStatsServer,
|
||||
runUpdateCommand: context.runUpdateCommand,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -200,6 +200,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
app: {
|
||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
ensureBackgroundStatsServer?: CliCommandDepsRuntimeOptions['app']['ensureBackgroundStatsServer'];
|
||||
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||
@@ -402,6 +403,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
app: {
|
||||
stop: params.app.stop,
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
ensureBackgroundStatsServer: params.app.ensureBackgroundStatsServer,
|
||||
runUpdateCommand: params.app.runUpdateCommand,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||
|
||||
@@ -926,3 +926,156 @@ test('waitForModalOpen resolves false on timeout', async () => {
|
||||
|
||||
assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false);
|
||||
});
|
||||
|
||||
test('modal placement reconcile retries until the Hyprland client is mapped', () => {
|
||||
const window = createMockWindow();
|
||||
const timers: Array<() => void> = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = ((cb: () => void) => {
|
||||
timers.push(cb);
|
||||
return { unref() {} };
|
||||
}) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
const statuses: Array<{ applicable: boolean; clientFound: boolean }> = [];
|
||||
try {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {
|
||||
// The compositor never maps the window, so every reconcile reports pending.
|
||||
const status = { applicable: true, clientFound: false, dispatched: false };
|
||||
statuses.push(status);
|
||||
return status;
|
||||
},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
{ test: true },
|
||||
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('kiku');
|
||||
|
||||
let iterations = 0;
|
||||
while (timers.length > 0 && iterations < 50) {
|
||||
const next = timers.shift();
|
||||
next?.();
|
||||
iterations += 1;
|
||||
}
|
||||
|
||||
// The reconcile ladder re-asserts placement across all six delays while the client
|
||||
// stays unmapped, instead of the old single post-show attempt.
|
||||
assert.ok(
|
||||
statuses.length >= 6,
|
||||
`expected at least 6 pending reconcile attempts, saw ${statuses.length}`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('modal placement reconcile stops retrying once the client is mapped', () => {
|
||||
const window = createMockWindow();
|
||||
const timers: Array<() => void> = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
globalThis.setTimeout = ((cb: () => void) => {
|
||||
timers.push(cb);
|
||||
return { unref() {} };
|
||||
}) as unknown as typeof globalThis.setTimeout;
|
||||
|
||||
let reconcileCount = 0;
|
||||
try {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {
|
||||
reconcileCount += 1;
|
||||
// Client is already mapped, so placement is settled on the first attempt.
|
||||
return { applicable: true, clientFound: true, dispatched: true };
|
||||
},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
{ test: true },
|
||||
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('kiku');
|
||||
|
||||
let iterations = 0;
|
||||
while (timers.length > 0 && iterations < 50) {
|
||||
const next = timers.shift();
|
||||
next?.();
|
||||
iterations += 1;
|
||||
}
|
||||
|
||||
// No 6-deep ladder: a settled placement should not keep rescheduling.
|
||||
assert.ok(
|
||||
reconcileCount < 6,
|
||||
`expected the ladder to stop early, saw ${reconcileCount} reconcile attempts`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test('modal placement reconcile cancels stale retry ladder after a newer visible modal interaction', () => {
|
||||
const window = createMockWindow();
|
||||
type TimerEntry = { active: boolean; callback: () => void };
|
||||
const timers: TimerEntry[] = [];
|
||||
const activeTimerCount = () => timers.filter((timer) => timer.active).length;
|
||||
const runNextActiveTimer = () => {
|
||||
const timer = timers.find((candidate) => candidate.active);
|
||||
if (!timer) return;
|
||||
timer.active = false;
|
||||
timer.callback();
|
||||
};
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
globalThis.setTimeout = ((cb: () => void) => {
|
||||
const timer = { active: true, callback: cb, unref() {} };
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}) as unknown as typeof globalThis.setTimeout;
|
||||
globalThis.clearTimeout = ((timeout: TimerEntry | undefined) => {
|
||||
if (timeout) {
|
||||
timeout.active = false;
|
||||
}
|
||||
}) as unknown as typeof globalThis.clearTimeout;
|
||||
|
||||
try {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => ({ applicable: true, clientFound: false, dispatched: false }),
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
{ test: true },
|
||||
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||
);
|
||||
runtime.notifyOverlayModalOpened('kiku');
|
||||
assert.equal(activeTimerCount(), 1);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
{ test: true },
|
||||
{ restoreOnModalClose: 'kiku', preferModalWindow: true },
|
||||
);
|
||||
assert.equal(activeTimerCount(), 2);
|
||||
|
||||
runNextActiveTimer();
|
||||
|
||||
assert.equal(activeTimerCount(), 1, 'stale retry should not schedule a continuation');
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
|
||||
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||
// before the compositor has a client to place, leaving the modal buried under fullscreen mpv.
|
||||
// Re-assert placement across this ladder until the Hyprland client is found (or attempts run out).
|
||||
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS = [50, 120, 250, 500, 900, 1400];
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
@@ -31,7 +35,7 @@ export interface OverlayWindowResolver {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
createModalWindow: () => BrowserWindow | null;
|
||||
getModalGeometry: () => WindowGeometry;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => HyprlandPlacementStatus | void;
|
||||
}
|
||||
|
||||
export interface OverlayModalRuntime {
|
||||
@@ -73,6 +77,7 @@ export function createOverlayModalRuntimeService(
|
||||
let modalWindowPrimedForImmediateShow = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
@@ -145,21 +150,47 @@ export function createOverlayModalRuntimeService(
|
||||
window.moveTop();
|
||||
};
|
||||
|
||||
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
|
||||
const reconcileModalWindowBounds = (window: BrowserWindow): HyprlandPlacementStatus | void => {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
return deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
};
|
||||
|
||||
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
|
||||
const nextModalWindowBoundsReconcileGeneration = (window: BrowserWindow): number => {
|
||||
const generation = (modalWindowBoundsReconcileGenerations.get(window) ?? 0) + 1;
|
||||
modalWindowBoundsReconcileGenerations.set(window, generation);
|
||||
return generation;
|
||||
};
|
||||
|
||||
const isCurrentModalWindowBoundsReconcileGeneration = (
|
||||
window: BrowserWindow,
|
||||
generation: number,
|
||||
): boolean => modalWindowBoundsReconcileGenerations.get(window) === generation;
|
||||
|
||||
const scheduleModalWindowBoundsReconcile = (
|
||||
window: BrowserWindow,
|
||||
generation: number,
|
||||
attempt = 0,
|
||||
): void => {
|
||||
if (attempt >= MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS.length) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isCurrentModalWindowBoundsReconcileGeneration(window, generation)) {
|
||||
return;
|
||||
}
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return;
|
||||
}
|
||||
reconcileModalWindowBounds(window);
|
||||
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
|
||||
const status = reconcileModalWindowBounds(window);
|
||||
// Keep retrying only while a Hyprland placement is applicable but the compositor has
|
||||
// not mapped the window yet. Once the client is found (or we're not on Hyprland), stop.
|
||||
if (status && status.applicable && !status.clientFound) {
|
||||
scheduleModalWindowBoundsReconcile(window, generation, attempt + 1);
|
||||
}
|
||||
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAYS_MS[attempt]);
|
||||
timeout.unref?.();
|
||||
};
|
||||
|
||||
@@ -206,8 +237,9 @@ export function createOverlayModalRuntimeService(
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||
reconcileModalWindowBounds(window);
|
||||
scheduleModalWindowBoundsReconcile(window);
|
||||
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||
};
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
@@ -219,8 +251,9 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.isVisible()) {
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||
reconcileModalWindowBounds(window);
|
||||
scheduleModalWindowBoundsReconcile(window);
|
||||
scheduleModalWindowBoundsReconcile(window, reconcileGeneration);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createEnsureBackgroundStatsServerHandler } from './background-stats-startup';
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<Parameters<typeof createEnsureBackgroundStatsServerHandler>[0]> = {},
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const deps: Parameters<typeof createEnsureBackgroundStatsServerHandler>[0] = {
|
||||
isStatsAutoStartEnabled: () => true,
|
||||
isImmersionTrackingEnabled: () => true,
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
calls.push('ensureBackgroundStatsServerStarted');
|
||||
return { url: 'http://127.0.0.1:3888', runningInCurrentProcess: true };
|
||||
},
|
||||
logInfo: (message) => {
|
||||
calls.push(`info:${message}`);
|
||||
},
|
||||
logWarn: (message) => {
|
||||
calls.push(`warn:${message}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { deps, calls };
|
||||
}
|
||||
|
||||
test('ensures background stats server and logs local startup', () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.ok(calls.includes('ensureBackgroundStatsServerStarted'));
|
||||
assert.ok(
|
||||
calls.some((value) => value.startsWith('info:') && value.includes('http://127.0.0.1:3888')),
|
||||
);
|
||||
});
|
||||
|
||||
test('logs reuse when a background stats server is already running', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
ensureBackgroundStatsServerStarted: () => ({
|
||||
url: 'http://127.0.0.1:3888',
|
||||
runningInCurrentProcess: false,
|
||||
}),
|
||||
});
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.ok(
|
||||
calls.some((value) => value.startsWith('info:') && /already running|reusing/i.test(value)),
|
||||
);
|
||||
});
|
||||
|
||||
test('skips when stats.autoStartServer is disabled', () => {
|
||||
const { deps, calls } = createDeps({ isStatsAutoStartEnabled: () => false });
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||
});
|
||||
|
||||
test('skips when immersion tracking is disabled', () => {
|
||||
const { deps, calls } = createDeps({ isImmersionTrackingEnabled: () => false });
|
||||
|
||||
createEnsureBackgroundStatsServerHandler(deps)();
|
||||
|
||||
assert.equal(calls.includes('ensureBackgroundStatsServerStarted'), false);
|
||||
});
|
||||
|
||||
test('logs a warning instead of throwing when startup fails', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
throw new Error('port in use');
|
||||
},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => createEnsureBackgroundStatsServerHandler(deps)());
|
||||
assert.ok(calls.some((value) => value.startsWith('warn:')));
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface EnsureBackgroundStatsServerDeps {
|
||||
isStatsAutoStartEnabled: () => boolean;
|
||||
isImmersionTrackingEnabled: () => boolean;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function createEnsureBackgroundStatsServerHandler(
|
||||
deps: EnsureBackgroundStatsServerDeps,
|
||||
): () => void {
|
||||
return () => {
|
||||
if (!deps.isStatsAutoStartEnabled()) {
|
||||
deps.logInfo('Background start: stats.autoStartServer is disabled; skipping stats server.');
|
||||
return;
|
||||
}
|
||||
if (!deps.isImmersionTrackingEnabled()) {
|
||||
deps.logInfo('Background start: immersion tracking is disabled; skipping stats server.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = deps.ensureBackgroundStatsServerStarted();
|
||||
deps.logInfo(
|
||||
result.runningInCurrentProcess
|
||||
? `Background start: stats server started at ${result.url}.`
|
||||
: `Background start: stats server already running at ${result.url}; skipping.`,
|
||||
);
|
||||
} catch (error) {
|
||||
deps.logWarn('Background start: failed to start stats server.', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
ensureBackgroundStatsServer?: CliCommandContextFactoryDeps['ensureBackgroundStatsServer'];
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
ensureBackgroundStatsServer: deps.ensureBackgroundStatsServer,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||