Compare commits

...

21 Commits

Author SHA1 Message Date
sudacode a0fec40a13 fix(anki): harden AnkiConnect requests and yomitan runtime fallback
- add request timeout and HTTP status check to verify-known-word-highlights AnkiConnect client
- let yomitan-script-runtime search on its own when the resolved manifest path isn't usable
- dedupe repeated tier-query setup in known-word-cache-maturity tests via setTierQueries helper
2026-07-27 00:26:40 -07:00
sudacode 48c36aa195 refactor(anki): parse the known-word cache in one shared module
The cache manager and the stats server each carried a hand-written parser
for the same file. When the format went to v4 for maturity tiers only the
manager was updated, so loadKnownWordsSet fell through to null and every
session reported 0 known words from an intact cache.

Move the state union, its parser, and a knownWordsFromState accessor into
known-word-cache-format and have both readers go through it. The stats
server no longer mentions a version at all, and both version switches close
with assertNever, so adding a V5 is a compile error at every consumer
instead of a silently empty result. An unparseable cache now warns rather
than reading as "no known words".

Two stats fixtures omitted refreshedAtMs/scope and only passed because the
stats reader was laxer than the manager; they now carry the fields every
cache the app writes has always had.
2026-07-26 01:33:04 -07:00
sudacode 105bd86410 feat(scripts): add a known-word highlight verifier
Adds verify-known-word-highlights:electron, which tokenizes a subtitle file
through the real Yomitan/MeCab pipeline against the live known-word cache
and prints each cue in the configured tier colors, so highlighting can be
checked without stepping through playback.

--audit re-derives every rendered tier from live Anki card data (notesInfo
plus cardsInfo intervals) and reports tokens whose color disagrees, which
catches stale cache entries and tier-classification bugs alike. --profile-copy
runs against a scratch Yomitan profile so the check works while SubMiner
holds the userData lock.

Exposes KnownWordCacheManager.getKnownWordMatchNoteIds and the annotation
stage's known-word text/reading resolvers so the audit can trace a rendered
tier back to the exact notes behind it.
2026-07-26 01:33:04 -07:00
sudacode 75566a49bf fix(overlay): gate the maturity legend on known-word highlighting
The session help color legend showed the four tier rows whenever
maturityEnabled was on, even with known-word highlighting off, where no
token is ever tinted. Extract isKnownWordMaturityLegendEnabled so the
legend requires both toggles, matching getKnownWordMaturityEnabled.
2026-07-26 01:33:04 -07:00
sudacode 38060e0ead fix(anki): exclude learning cards from interval maturity tiers
The interval tier queries matched any card with prop:ivl>=1, but a lapsed
card keeps an interval of at least the lapse minimum, so relearning cards
were caught by the young query first and the learning tier was unreachable
in practice. Add -is:learn to the mature and young queries so the buckets
stay disjoint and match Anki's own card counts; a note with a mature card
alongside a relearning card still resolves to mature.

Bump KNOWN_WORD_MATURITY_RULES_VERSION and fold it into the cache scope key
so caches built under the old rules refetch instead of serving stale tiers.
2026-07-26 01:32:50 -07:00
sudacode 77badd7a54 feat(anki): resolve known-word maturity config and show tiers in help le
- Add `ankiConnect.knownWords.maturityEnabled`/`matureThresholdDays` config resolution with validation, warnings, and fallback to defaults
- Session help color legend shows one row per maturity tier (new/learning/young/mature) when maturity coloring is on, instead of a single known-words swatch
- Update docs and changelog entry to describe the new legend behavior
2026-07-26 00:39:52 -07:00
sudacode 33b9d97d63 fix(character-dictionary): prioritize directory-scoped override over leg
- Check directory-scoped overrides before falling back to legacy season-key matches, so a same-directory scoped entry wins over a stale legacy fallback
- Add regression test covering same-directory override vs legacy fallback precedence
- Bump electron-builder/electron-updater/eslint and related transitive deps (bun.lock, package.json)
2026-07-26 00:39:52 -07:00
sudacode 9eb7f93a6b fix(character-dictionary): scope manual overrides to season directory
- Match/replace AniList overrides by directory scope instead of exact series key, so later episodes keep the override even when filename guesses differ
- Add tests covering differing-guess matches and same-directory override replacement
- Update docs-site/character-dictionary.md and add changelog entry
2026-07-26 00:39:52 -07:00
sudacode 88d0fe6b98 feat(settings): group Anki maturity fields under Known Words
- Add maturityEnabled, matureThresholdDays, and per-tier maturity colors to Known Words subsection
- Order maturity color fields new -> learning -> young -> mature
- Route knownWordMaturityColors.* paths through the color control
- Add registry tests for subsection grouping and field ordering
2026-07-26 00:39:52 -07:00
sudacode ffae99caa8 fix(anki): keep existing maturity tier on re-mined notes
- appendFromNoteInfo no longer overwrites a note's tracked tier with 'new' when the note already has one recorded
- add test covering re-mining a note that already has a mature/learning tier
2026-07-26 00:39:52 -07:00
sudacode d140ae6e56 fix(ci): address PR review and quality gate 2026-07-26 00:39:52 -07:00
sudacode e0dbe5c8cd fix(anki): preserve known-word cache when maturity fetch fails
- Catch errors from fetchKnownWordMaturityTierSets so a failed tier query no longer aborts the whole refresh
- Known words still cache; tiers just stay null until the next successful refresh
- Add regression test covering findNotes failure on a tier query
2026-07-26 00:39:52 -07:00
sudacode bc8dce870b feat(overlay): Anki maturity-based known-word highlighting
Color known-word subtitle highlights by Anki card maturity (new,
learning, young, mature) like asbplayer (#171). Notes are classified
server-side with Anki search filters (prop:ivl, is:learn) during the
known-word cache refresh, so no per-card data is fetched. A word's tier
is its most mature matching card/note, with the same reading-aware
matching as boolean known-word lookups.

- known-word cache v4 state persists per-note tiers; lifecycle key only
  gains the maturity field while enabled so existing caches survive
- ankiConnect.knownWords.maturityEnabled + matureThresholdDays (21)
- subtitleStyle.knownWordMaturityColors with catppuccin defaults
- word-maturity-<tier> class rides on word-known so hover/selection
  rules keep applying; falls back to knownWordColor without tier data
- runtime toggle subtitle.annotation.knownWords.maturityEnabled
2026-07-26 00:39:52 -07:00
sudacode 08c6807cb1 fix(launcher): normalize rofi prompt spacing
- Add formatRofiPrompt to trim trailing whitespace and append a single space, keeping the prompt from running into the input field
- Apply it across all rofi -p usages in picker.ts and history-command.ts
- Add tests for formatRofiPrompt edge cases (empty/whitespace-only prompts)
2026-07-26 00:13:54 -07:00
sudacode 18c6410f24 feat(launcher): add previous episode option to history entry menu
- Extract buildHistoryEntryActions to build the series action menu, now including a previous-episode option alongside replay/next/browse/quit
- Update docs and changelog entry to describe the new option
- Add tests covering previous/replay/next ordering and omission when last watched file is missing
2026-07-25 23:35:44 -07:00
sudacode e223cf9b71 fix(release): restore compatible js-yaml override 2026-07-22 23:51:21 -07:00
sudacode c718bb8b34 chore(release): prepare 0.19.0-beta.2 2026-07-22 23:34:08 -07:00
sudacode 177f7e5fd6 docs(readme): remove TsukiHime credit
- Drop the TsukiHime row from the open-source credits table
2026-07-22 23:07:07 -07:00
sudacode 20b50ec4e1 feat(launcher): add post-playback history menu with previous episode (#170) 2026-07-22 23:05:53 -07:00
sudacode deae61b211 refactor: split anki-connect and stats-server resolvers into modules (#169) 2026-07-17 23:05:59 -07:00
sudacode 44959ed282 fix(anki,appimage): handle proxy port conflicts and stray GPU child
- AnkiConnect proxy now detects EADDRINUSE, logs a warning, and surfaces an overlay notification instead of crashing video startup
- Background AppImage bootstrap runs Chromium with in-process-gpu so no GPU child survives app.exit() and outlives the FUSE mount, fixing the remaining DrKonqi "Service Crash" case
- Add changelog fragment for the proxy fix; update the AppImage quit fragment with the GPU child root cause
2026-07-17 23:04:56 -07:00
107 changed files with 7173 additions and 2811 deletions
+1 -2
View File
@@ -213,7 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
subminer video.mkv # launch mpv with SubMiner
subminer /path/to/dir # pick a file with fzf
subminer -R /path/to/dir # pick a file with rofi (Linux only)
subminer -H # browse local watch history (replay / next episode / browse)
subminer -H # browse history, then previous / replay / next / select / quit
```
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
@@ -238,7 +238,6 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
+75 -188
View File
@@ -10,7 +10,7 @@
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -21,9 +21,9 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.8.2",
"electron-builder": "26.15.3",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
@@ -35,20 +35,19 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"electron-builder-squirrel-windows": "26.15.3",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7",
},
"packages": {
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
@@ -67,7 +66,7 @@
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
"@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="],
"@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="],
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
@@ -131,13 +130,13 @@
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
@@ -153,8 +152,6 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
@@ -181,11 +178,15 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
@@ -215,12 +216,8 @@
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
@@ -229,7 +226,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -239,21 +236,15 @@
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
"app-builder-lib": ["app-builder-lib@26.8.2", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.2", "electron-builder-squirrel-windows": "26.8.2" } }, "sha512-z3ptLzJwNl35fyR0wxv4qWOfZuU36VysYHnbs8PDtf8S0QzIl2OWimdDFVmCxYMkIV1k/RT9CeTgcP7oUznFOw=="],
"app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
@@ -263,27 +254,27 @@
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
@@ -299,16 +290,8 @@
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -323,8 +306,6 @@
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -335,8 +316,6 @@
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@@ -353,9 +332,7 @@
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
"dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -363,26 +340,24 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-kXhajX6DzdIQcTlctVTKoG1oO39JhWcTG0lH7ZEJ4FzPaKJy7KFNfNJUd5BoEmLjv5GlrRZpEOYnniD+LcwNJA=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="],
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
@@ -405,7 +380,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
@@ -423,14 +398,14 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -445,14 +420,10 @@
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -501,12 +472,6 @@
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -515,29 +480,23 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -571,16 +530,12 @@
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -591,26 +546,14 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
"minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
@@ -619,17 +562,15 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
@@ -637,36 +578,28 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
@@ -675,7 +608,9 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
@@ -689,31 +624,33 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
@@ -733,39 +670,25 @@
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
"tar": ["tar@7.5.21", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA=="],
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
@@ -795,21 +718,17 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
"unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
@@ -817,8 +736,6 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
@@ -855,42 +772,26 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -899,35 +800,25 @@
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -935,8 +826,6 @@
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
@@ -947,6 +836,8 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -955,11 +846,7 @@
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
@@ -0,0 +1,5 @@
type: added
area: overlay
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: app
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
@@ -0,0 +1,4 @@
type: fixed
area: character dictionary
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: launcher
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: overlay
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. TsukiHime carries the Animetosho index and mirrors its attachment storage, so older releases stay reachable. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section.
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
@@ -0,0 +1,7 @@
type: internal
area: tokenizer
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
+8
View File
@@ -433,6 +433,12 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -569,6 +575,8 @@
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"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
+1 -1
View File
@@ -223,7 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
## Managing Loaded Entries
+1
View File
@@ -407,6 +407,7 @@ See `config.example.jsonc` for detailed configuration options.
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
+7 -3
View File
@@ -73,9 +73,13 @@ subminer -R -H # rofi history browser
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
- **Replay last watched** — replays the most recently watched episode
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
- **Replay last watched**: replays the most recently watched episode
- **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
- **Quit SubMiner**: closes the history session without starting an episode
After an episode ends or you close mpv, the launcher returns to an action menu for the same series. The menu lists Previous, Rewatch, Next, Select episode, and Quit SubMiner in that order, omitting Previous or Next when no episode exists in that direction. Choosing Previous or Next can move between season directories. After you play another episode, Previous, Rewatch, and Next use it instead of the older database entry. Pressing Escape closes the history session.
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
+8
View File
@@ -433,6 +433,12 @@
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
"knownWordMaturityColors": {
"new": "#ee99a0", // Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.
"learning": "#b7bdf8", // Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.
"young": "#91d7e3", // Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.
"mature": "#a6da95" // Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.
}, // Known word maturity colors setting.
"jlptColors": {
"N1": "#ed8796", // N1 setting.
"N2": "#f5a97f", // N2 setting.
@@ -569,6 +575,8 @@
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
"maturityEnabled": false, // Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting. Values: true | false
"matureThresholdDays": 21, // Card interval in days at which a known word counts as mature (Anki convention: 21).
"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
+40 -1
View File
@@ -43,6 +43,44 @@ Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only f
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
:::
## Known-Word Maturity Highlighting
Instead of one color for every known word, maturity highlighting tints each known token by the review state of its Anki cards (like asbplayer), giving an at-a-glance sense of how much of a line is solidly learned.
**How it works:**
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded.
2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`.
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
**Key settings:**
| Option | Default | Description |
| ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue |
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules.
How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead.
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
**Checking the colors you actually see:**
Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file:
```sh
bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit
```
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
## Character-Name Highlighting
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles - portraits, roles, voice actors, and biographical detail.
@@ -131,6 +169,7 @@ All colors are customizable via the `subtitleStyle.jlptColors` object.
These annotation layers can be toggled at runtime via the runtime options palette (`Ctrl/Cmd+Shift+O`) without restarting:
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`)
- `ankiConnect.knownWords.matchMode`
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
- `subtitleStyle.enableJlpt` (`On` / `Off`)
@@ -146,6 +185,6 @@ When multiple annotations apply to the same token, the visual priority is:
1. **Character-name match** (highest) - dictionary-driven character-name token styling; it clears the token's N+1, frequency, and JLPT annotations
2. **N+1 target** - the single unknown word in an N+1 sentence
3. **Known-word color** - already-learned token tint
3. **Known-word color** - already-learned token tint (per-tier maturity colors when `maturityEnabled` is on)
4. **Frequency highlight** - common-word coloring (not applied when a higher layer already matched)
5. **JLPT underline** - level-based underline (stacks with N+1/known/frequency since it uses underline rather than text color, but not with a character-name match)
+1 -1
View File
@@ -93,7 +93,7 @@ subminer --start video.mkv # Explicit overlay start (use when mpv.autoSta
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer -H # Browse watch history (replay/continue episodes, fzf or rofi picker)
subminer -H # Browse history, then choose previous/replay/next after playback
subminer app --setup # Open first-run setup popup
subminer --version # Print the launcher's version
subminer -v # Same as above
+7 -3
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-05-23
Last verified: 2026-07-15
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -16,7 +16,9 @@ Read when: you need to find the owner module for a behavior or test surface
## Product / Integration Domains
- Config system: `src/config/`
- Config system: `src/config/`; Anki resolution is composed by
`src/config/resolve/anki-connect.ts` from focused resolvers in
`src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts`
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
@@ -26,7 +28,9 @@ Read when: you need to find the owner module for a behavior or test surface
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/`
- Stats app: `stats/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/`
- Stats SPA: `stats/`
- Public docs site: `docs-site/`
## Shared Contract Entry Points
+193 -15
View File
@@ -7,11 +7,13 @@ import {
collectVideos,
findRofiTheme,
formatPickerLaunchError,
formatRofiPrompt,
showFzfMenu,
showRofiMenu,
} from '../picker.js';
import {
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
listSeasonDirs,
materializeCoverArt,
@@ -23,6 +25,167 @@ import {
import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
export interface HistoryPlaybackSelection {
entry: HistorySeriesEntry;
videoPath: string;
themePath?: string | null;
entryIcon?: string | null;
}
interface HistorySessionMenuAction {
kind: HistorySessionAction;
label: string;
}
export function buildHistorySessionActions(
justPlayedPath: string,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
actions.push({
kind: 'replay',
label: `Rewatch episode: ${path.basename(justPlayedPath)}`,
});
if (nextEpisodePath) {
actions.push({
kind: 'next',
label: `Play next episode: ${path.basename(nextEpisodePath)}`,
});
}
actions.push(
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
export function buildHistoryEntryActions(
lastWatchedPath: string | null,
previousEpisodePath: string | null,
nextEpisodePath: string | null,
): HistorySessionMenuAction[] {
const actions: HistorySessionMenuAction[] = [];
if (previousEpisodePath) {
actions.push({
kind: 'previous',
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
});
}
if (lastWatchedPath) {
actions.push({
kind: 'replay',
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
});
}
if (nextEpisodePath) {
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
}
actions.push(
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
);
return actions;
}
interface HistoryPlaybackLoopDeps {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<HistorySessionAction | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
}
export async function runHistoryPlaybackLoop(
initial: HistoryPlaybackSelection,
deps: HistoryPlaybackLoopDeps,
): Promise<void> {
let videoPath = initial.videoPath;
while (true) {
await deps.play(videoPath);
const previousEpisodePath = deps.findPreviousEpisode(videoPath);
const nextEpisodePath = deps.findNextEpisode(videoPath);
const action = await deps.pickPostPlaybackAction({
entry: initial.entry,
justPlayedPath: videoPath,
previousEpisodePath,
nextEpisodePath,
});
switch (action) {
case 'replay':
break;
case 'previous':
if (!previousEpisodePath) return;
videoPath = previousEpisodePath;
break;
case 'next':
if (!nextEpisodePath) return;
videoPath = nextEpisodePath;
break;
case 'browse': {
const browsedPath = await deps.browseEpisodes(initial.entry);
if (!browsedPath) return;
videoPath = browsedPath;
break;
}
case 'quit':
case null:
return;
}
}
}
export async function runHistorySession(
context: LauncherCommandContext,
play: (videoPath: string) => Promise<void>,
): Promise<boolean> {
const initial = await runHistoryCommand(context);
if (!initial) return false;
await runHistoryPlaybackLoop(initial, {
play,
findPreviousEpisode,
findNextEpisode,
browseEpisodes: async (entry) => browseEpisodes(entry, context, initial.themePath ?? null),
pickPostPlaybackAction: async ({
entry,
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
}) => {
const actions = buildHistorySessionActions(
justPlayedPath,
previousEpisodePath,
nextEpisodePath,
);
const actionIdx = pickIndex(
actions.map((action) => action.label),
entry.displayName,
context.args.useRofi,
initial.themePath ?? null,
actions.map(() => initial.entryIcon ?? null),
);
return actionIdx < 0 ? null : actions[actionIdx]!.kind;
},
});
return true;
}
function checkPickerDependencies(args: Args): void {
if (args.useRofi) {
if (!commandExists('rofi')) fail('Missing dependency: rofi');
@@ -37,7 +200,16 @@ function showRofiIndexMenu(
themePath: string | null,
icons: Array<string | null> = [],
): number {
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
const rofiArgs = [
'-dmenu',
'-i',
'-matching',
'fuzzy',
'-format',
'i',
'-p',
formatRofiPrompt(prompt),
];
const hasIcons = icons.some(Boolean);
if (hasIcons) rofiArgs.push('-show-icons');
if (themePath) {
@@ -142,7 +314,7 @@ function browseEpisodes(
if (seasons.length > 1) {
const idx = pickIndex(
seasons.map((season) => season.name),
`${entry.displayName} Season`,
`${entry.displayName}: Season`,
args.useRofi,
themePath,
);
@@ -155,7 +327,9 @@ function browseEpisodes(
return pickEpisodeFromDir(dir, context);
}
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
export async function runHistoryCommand(
context: LauncherCommandContext,
): Promise<HistoryPlaybackSelection | null> {
const { args, scriptPath } = context;
checkPickerDependencies(args);
@@ -196,16 +370,14 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
const lastPath = path.resolve(entry.lastWatched.sourcePath);
const lastExists = fs.existsSync(lastPath);
const previousEpisode = findPreviousEpisode(lastPath);
const nextEpisode = findNextEpisode(lastPath);
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
if (lastExists) {
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
}
if (nextEpisode) {
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
}
actions.push({ kind: 'browse', label: 'Browse episodes' });
const actions = buildHistoryEntryActions(
lastExists ? lastPath : null,
previousEpisode,
nextEpisode,
);
const entryIcon = seriesIcons[seriesIdx] ?? null;
const actionIdx = pickIndex(
@@ -219,10 +391,16 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
switch (actions[actionIdx]!.kind) {
case 'replay':
return lastPath;
return { entry, videoPath: lastPath, themePath, entryIcon };
case 'previous':
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
case 'next':
return nextEpisode;
case 'browse':
return browseEpisodes(entry, context, themePath);
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
case 'browse': {
const videoPath = browseEpisodes(entry, context, themePath);
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
}
case 'quit':
return null;
}
}
+294
View File
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import {
buildHistoryEntryActions,
buildHistorySessionActions,
runHistoryPlaybackLoop,
} from './history-command.js';
import type { HistorySeriesEntry } from '../history.js';
type HistoryLoop = (
initial: { entry: HistorySeriesEntry; videoPath: string },
deps: {
play: (videoPath: string) => Promise<void>;
pickPostPlaybackAction: (input: {
entry: HistorySeriesEntry;
justPlayedPath: string;
previousEpisodePath: string | null;
nextEpisodePath: string | null;
}) => Promise<'previous' | 'replay' | 'next' | 'browse' | 'quit' | null>;
findPreviousEpisode: (videoPath: string) => string | null;
findNextEpisode: (videoPath: string) => string | null;
browseEpisodes: (entry: HistorySeriesEntry) => Promise<string | null>;
},
) => Promise<void>;
const typedRunHistoryPlaybackLoop: HistoryLoop = runHistoryPlaybackLoop;
function makeEntry(lastWatchedPath: string): HistorySeriesEntry {
return {
seriesRoot: path.dirname(lastWatchedPath),
displayName: 'Test Show',
coverBlobHash: null,
lastWatched: {
videoId: 1,
sourcePath: lastWatchedPath,
parsedTitle: 'Test Show',
parsedSeason: 1,
parsedEpisode: 1,
animeTitle: 'Test Show',
lastWatchedMs: 1,
coverBlobHash: null,
},
};
}
test('history loop plays an initial selection, browsed selection, then quits', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
const menuEntries: HistorySeriesEntry[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-02.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ entry: menuEntry }) => {
menuEntries.push(menuEntry);
return menuCount++ === 0 ? 'browse' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async (browseEntry) => {
assert.equal(browseEntry, entry);
return '/shows/test-show/episode-04.mkv';
},
},
);
assert.deepEqual(played, ['/shows/test-show/episode-02.mkv', '/shows/test-show/episode-04.mkv']);
assert.deepEqual(menuEntries, [entry, entry]);
});
test('history replay uses the actual just-played path instead of the database row', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const menuPaths: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ justPlayedPath }) => {
menuPaths.push(justPlayedPath);
return menuCount++ === 0 ? 'replay' : 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: () => '/shows/test-show/episode-08.mkv',
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-07.mkv']);
assert.deepEqual(menuPaths, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-07.mkv',
]);
});
test('history next is computed from the actual just-played path', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-01.mkv');
const played: string[] = [];
const nextInputs: string[] = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ nextEpisodePath }) => {
if (menuCount++ === 0) {
assert.equal(nextEpisodePath, '/shows/test-show/episode-08.mkv');
return 'next';
}
assert.equal(nextEpisodePath, null);
return 'quit';
},
findPreviousEpisode: () => null,
findNextEpisode: (videoPath) => {
nextInputs.push(videoPath);
return videoPath.endsWith('episode-07.mkv') ? '/shows/test-show/episode-08.mkv' : null;
},
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-08.mkv']);
assert.deepEqual(nextInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-08.mkv',
]);
});
test('history show menu offers previous, rewatch, next, browse, and quit in order', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(
buildHistorySessionActions(
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-08.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-06.mkv' },
{ kind: 'replay', label: 'Rewatch episode: episode-07.mkv' },
{ kind: 'next', label: 'Play next episode: episode-08.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history show menu omits previous and next when the just-played episode has neither', () => {
assert.equal(
typeof buildHistorySessionActions,
'function',
'history session actions are not implemented',
);
assert.deepEqual(buildHistorySessionActions('/shows/test-show/finale.mkv', null, null), [
{ kind: 'replay', label: 'Rewatch episode: finale.mkv' },
{ kind: 'browse', label: 'Select / browse episode' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history entry menu offers previous, replay, and next before playback starts', () => {
assert.equal(
typeof buildHistoryEntryActions,
'function',
'history entry actions not implemented',
);
assert.deepEqual(
buildHistoryEntryActions(
'/shows/test-show/episode-03.mkv',
'/shows/test-show/episode-02.mkv',
'/shows/test-show/episode-04.mkv',
),
[
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
],
);
});
test('history entry menu omits replay when the last watched file is gone', () => {
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
{ kind: 'browse', label: 'Browse episodes' },
{ kind: 'quit', label: 'Quit SubMiner' },
]);
});
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/stale-episode-09.mkv');
const played: string[] = [];
const previousInputs: string[] = [];
const previousSeenByMenu: Array<string | null> = [];
let menuCount = 0;
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-07.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
previousSeenByMenu.push(previousEpisodePath);
return menuCount++ === 0 ? 'previous' : 'quit';
},
findPreviousEpisode: (videoPath) => {
previousInputs.push(videoPath);
if (videoPath.endsWith('episode-07.mkv')) return '/shows/test-show/episode-06.mkv';
if (videoPath.endsWith('episode-06.mkv')) return '/shows/test-show/episode-05.mkv';
return null;
},
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-07.mkv', '/shows/test-show/episode-06.mkv']);
assert.deepEqual(previousInputs, [
'/shows/test-show/episode-07.mkv',
'/shows/test-show/episode-06.mkv',
]);
assert.deepEqual(previousSeenByMenu, [
'/shows/test-show/episode-06.mkv',
'/shows/test-show/episode-05.mkv',
]);
});
test('history playback loop stops advancing when previous is chosen with no prior episode', async () => {
assert.equal(
typeof runHistoryPlaybackLoop,
'function',
'history playback loop is not implemented',
);
const entry = makeEntry('/shows/test-show/episode-01.mkv');
const played: string[] = [];
await typedRunHistoryPlaybackLoop(
{ entry, videoPath: '/shows/test-show/episode-01.mkv' },
{
play: async (videoPath) => {
played.push(videoPath);
},
pickPostPlaybackAction: async ({ previousEpisodePath }) => {
assert.equal(previousEpisodePath, null);
return 'previous';
},
findPreviousEpisode: () => null,
findNextEpisode: () => null,
browseEpisodes: async () => null,
},
);
assert.deepEqual(played, ['/shows/test-show/episode-01.mkv']);
});
+15 -1
View File
@@ -5,7 +5,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
import { state } from '../mpv.js';
function createContext(): LauncherCommandContext {
@@ -103,6 +103,20 @@ function createContext(): LauncherCommandContext {
};
}
test('playback cleanup signal handlers are registered once across repeated sessions', () => {
assert.equal(typeof registerCleanup, 'function', 'cleanup registration is not exported');
const context = createContext();
const registeredSignals: NodeJS.Signals[] = [];
context.processAdapter.onSignal = (signal) => {
registeredSignals.push(signal);
};
registerCleanup(context);
registerCleanup(context);
assert.deepEqual(registeredSignals, ['SIGINT', 'SIGTERM']);
});
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
const calls: string[] = [];
const context = createContext();
+5 -1
View File
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
const SETUP_POLL_INTERVAL_MS = 500;
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
function getLauncherConfigDir(): string {
return getDefaultConfigDir({
@@ -92,8 +93,10 @@ async function chooseTarget(
return { target: selected, kind: 'file' };
}
function registerCleanup(context: LauncherCommandContext): void {
export function registerCleanup(context: LauncherCommandContext): void {
const { args, processAdapter } = context;
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
processAdapter.onSignal('SIGINT', () => {
stopOverlay(args);
processAdapter.exit(130);
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
stopOverlay(args);
processAdapter.exit(143);
});
cleanupRegisteredProcessAdapters.add(processAdapter);
}
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
+43
View File
@@ -130,3 +130,46 @@ export function findNextEpisode(lastPath: string): string | null {
return findFirstEpisodeInNextSeason(resolvedLast, dir);
}
function findLastEpisodeInPreviousSeason(resolvedCurrent: string, dir: string): string | null {
const seriesRoot = resolveSeriesRoot(resolvedCurrent);
if (seriesRoot === dir) return null;
const seasons = listSeasonDirs(seriesRoot);
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
const currentSeason = seasonNumberFromDirName(path.basename(dir));
const previousSeasonEntry =
currentIdx >= 0
? seasons[currentIdx - 1]
: seasons
.filter(
(season) =>
currentSeason !== null && season.season !== null && season.season < currentSeason,
)
.at(-1);
if (!previousSeasonEntry) return null;
const previousSeason = sortVideosByEpisode(collectVideos(previousSeasonEntry.path, false));
return previousSeason.at(-1) ?? null;
}
export function findPreviousEpisode(currentPath: string): string | null {
const resolvedCurrent = path.resolve(currentPath);
const dir = path.dirname(resolvedCurrent);
const episodes = sortVideosByEpisode(collectVideos(dir, false));
const idx = episodes.indexOf(resolvedCurrent);
if (idx >= 0) {
if (idx - 1 >= 0) return episodes[idx - 1]!;
} else {
const currentInfo = parseMediaInfo(resolvedCurrent);
if (currentInfo.episode !== null) {
const candidates = episodes.filter((episode) => {
const info = parseMediaInfo(episode);
return info.episode !== null && info.episode < currentInfo.episode!;
});
const candidate = candidates[candidates.length - 1];
if (candidate) return candidate;
}
}
return findLastEpisodeInPreviousSeason(resolvedCurrent, dir);
}
+50
View File
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
import {
detectImageExtension,
findNextEpisode,
findPreviousEpisode,
groupHistoryBySeries,
isReadonlyWalRetryError,
listSeasonDirs,
@@ -198,6 +199,55 @@ test('findNextEpisode advances seasons when a deleted file was the last episode'
}
});
test('findPreviousEpisode steps back within a season and across seasons', () => {
assert.equal(typeof findPreviousEpisode, 'function', 'findPreviousEpisode is not implemented');
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E03.mkv')),
path.join(season1, 'Show - S01E02.mkv'),
);
assert.equal(
findPreviousEpisode(path.join(season1, 'Show - S01E02.mkv')),
path.join(season1, 'Show - S01E01.mkv'),
);
assert.equal(findPreviousEpisode(path.join(season1, 'Show - S01E01.mkv')), null);
assert.equal(
findPreviousEpisode(path.join(season2, 'Show - S02E01.mkv')),
path.join(season1, 'Show - S01E03.mkv'),
);
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode falls back to episode numbers when file was removed', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E01.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
test('findPreviousEpisode falls back to prior season when a deleted file was the first episode', () => {
const seriesRoot = createSeriesTree();
try {
const season1 = path.join(seriesRoot, 'Season-1');
const season2 = path.join(seriesRoot, 'Season-2');
fs.rmSync(path.join(season2, 'Show - S02E01.mkv'));
const missing = path.join(season2, 'Show - S02E01 - Deleted Cut.mkv');
assert.equal(findPreviousEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
} finally {
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
}
});
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
function createHistoryDb(
+8 -6
View File
@@ -21,7 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runHistoryCommand } from './commands/history-command.js';
import { runHistorySession } from './commands/history-command.js';
import { runSyncCommand } from './commands/sync-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
@@ -149,13 +149,15 @@ async function main(): Promise<void> {
}
if (appContext.args.history) {
const selected = await runHistoryCommand(appContext);
if (!selected) {
const played = await runHistorySession(appContext, async (videoPath) => {
appContext.args.target = videoPath;
appContext.args.targetKind = 'file';
await runPlaybackCommand(appContext);
});
if (!played) {
log('info', args.logLevel, 'No watch history selection made, exiting');
return;
}
appContext.args.target = selected;
appContext.args.targetKind = 'file';
return;
}
await runPlaybackCommand(appContext);
+16 -1
View File
@@ -3,7 +3,22 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { findRofiTheme } from './picker';
import { findRofiTheme, formatRofiPrompt } from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
test('formatRofiPrompt appends a single trailing space', () => {
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
});
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
});
test('formatRofiPrompt leaves an empty prompt empty', () => {
assert.equal(formatRofiPrompt(''), '');
assert.equal(formatRofiPrompt(' '), '');
});
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
+13 -4
View File
@@ -17,13 +17,22 @@ export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
/**
* Rofi renders the prompt flush against the input field, so keep exactly one
* trailing space to separate them.
*/
export function formatRofiPrompt(prompt: string): string {
const trimmed = prompt.trimEnd();
return trimmed ? `${trimmed} ` : '';
}
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = '',
themePath: string | null = null,
): string {
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
if (themePath) {
args.push('-theme', themePath);
} else {
@@ -110,7 +119,7 @@ export async function promptOptionalJellyfinSearch(
themePath: string | null = null,
): Promise<string> {
if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
@@ -157,7 +166,7 @@ function showRofiIconMenu(
themePath: string | null = null,
): number {
if (entries.length === 0) return -1;
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push('-theme', themePath);
@@ -391,7 +400,7 @@ export function showRofiMenu(
'-dmenu',
'-i',
'-p',
'Select Video ',
formatRofiPrompt('Select Video'),
'-show-icons',
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}',
+12 -8
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0-beta.1",
"version": "0.19.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -13,6 +13,7 @@
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
@@ -82,13 +83,16 @@
},
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"electron-builder-squirrel-windows": "26.15.3",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.16",
"tar": "7.5.21",
"tmp": "0.2.7"
},
"keywords": [
@@ -109,7 +113,7 @@
"@xhayper/discord-rpc": "^1.3.4",
"axios": "^1.18.1",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"electron-updater": "^6.8.9",
"hono": "^4.12.28",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -120,10 +124,10 @@
"@types/node": "^24.10.0",
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.8.2",
"electron-builder": "26.15.3",
"undici": "7.28.0",
"esbuild": "^0.25.12",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3"
},
+30 -9
View File
@@ -6,29 +6,50 @@
### Added
- **Sync Stats & History**
- Keep mining stats and watch history in sync across machines over SSH, from a new **Sync Stats & History** window (tray menu) or the `subminer sync <host>` command.
- Syncing merges data safely, so nothing is duplicated even if you sync the same machines repeatedly, and hosts with auto-sync enabled sync in the background on a schedule, reporting results as overlay notifications.
- Manual database snapshots (create, merge, reveal, delete) cover one-off transfers, and Windows machines running the built-in OpenSSH Server can be used as sync remotes too. No setup beyond SSH access is required on the remote side.
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
- **TsukiHime Subtitle Downloads**
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow: `Ctrl+Shift+T` opens an in-overlay search modal with separate tabs for the primary and secondary languages.
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
- **Post-Playback History Menu**
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
### Changed
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
- **Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
### Fixed
- **Word Highlighting Accuracy**: Fixed several cases of incorrect word highlighting and annotations, including inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary being skipped for next-level ("N+1") highlighting.
- **Startup Playback Pausing Too Early**: Fixed playback resuming before subtitle processing had finished warming up, which could briefly show untranslated subtitles right after opening a video, most noticeable when resuming mid-episode.
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- **Word Highlighting Accuracy**
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
- **Startup Playback Pausing Too Early**
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
- **Linux AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
- **AnkiConnect Proxy Port Conflict**
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
- **Stats & Settings Reliability**
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
## Installation
+593
View File
@@ -0,0 +1,593 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
KnownWordCacheManager,
getKnownWordCacheLifecycleConfig,
} from '../src/anki-integration/known-word-cache.js';
import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js';
import { resolveConfigDir } from '../src/config/path-resolution.js';
import { ConfigService } from '../src/config/service.js';
import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js';
import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js';
import {
resolveCompleteTokenReading,
resolveKnownWordReadingForMatch,
resolveKnownWordText,
} from '../src/core/services/tokenizer/annotation-stage.js';
import { MecabTokenizer } from '../src/mecab-tokenizer.js';
import type { MergedToken } from '../src/types.js';
import type { KnownWordMaturityTier } from '../src/types/subtitle.js';
import {
createYomitanRuntimeStateWithSearch,
destroyParserWindow,
loadElectronModule,
withTimeout,
type YomitanRuntimeState,
} from './yomitan-script-runtime.js';
interface CliOptions {
input: string;
configDir?: string;
yomitanUserDataPath?: string;
yomitanExtensionPath?: string;
limit: number;
audit: boolean;
refresh: boolean;
json: boolean;
quiet: boolean;
profileCopy: boolean;
}
type TierOrFallback = KnownWordMaturityTier | 'known-no-tier';
interface TokenReport {
cueIndex: number;
startTime: number;
surface: string;
headword: string;
reading: string;
tier: TierOrFallback;
noteIds: number[];
}
interface AuditMismatch extends TokenReport {
liveTier: KnownWordMaturityTier | 'no-notes';
intervals: number[];
}
const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
const FALLBACK_TIER_COLORS: Record<KnownWordMaturityTier, string> = {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
};
function parseCliArgs(argv: string[]): CliOptions {
const options: CliOptions = {
input: '',
limit: 0,
audit: false,
refresh: false,
json: false,
quiet: false,
profileCopy: false,
};
const rest: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const takeValue = (flag: string): string => {
const next = argv[i + 1];
if (!next) {
throw new Error(`Missing value for ${flag}`);
}
i += 1;
return next;
};
if (arg === '--help' || arg === '-h') {
process.stdout.write(`${usage()}\n`);
process.exit(0);
} else if (arg === '--input') {
options.input = takeValue(arg);
} else if (arg === '--config-dir') {
options.configDir = takeValue(arg);
} else if (arg === '--yomitan-user-data') {
options.yomitanUserDataPath = takeValue(arg);
} else if (arg === '--yomitan-extension-path') {
options.yomitanExtensionPath = takeValue(arg);
} else if (arg === '--limit') {
options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0);
} else if (arg === '--audit') {
options.audit = true;
} else if (arg === '--refresh') {
options.refresh = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--quiet') {
options.quiet = true;
} else if (arg === '--profile-copy') {
options.profileCopy = true;
} else if (arg === '--') {
// `bun run <script> -- --flag ...` forwards the separator too.
continue;
} else if (arg.startsWith('--')) {
throw new Error(`Unknown flag: ${arg}`);
} else {
rest.push(arg);
}
}
if (!options.input && rest.length > 0) {
options.input = rest.join(' ');
}
if (!options.input) {
throw new Error(`No subtitle file given.\n${usage()}`);
}
return options;
}
function usage(): string {
return [
'Usage: verify-known-word-highlights <subtitle.srt|.ass> [flags]',
'',
' --limit <n> Only check the first n cues (default: all)',
' --audit Re-derive every highlighted tier from live Anki card data',
' --refresh Force a known-word cache refresh before checking',
' --json Emit a machine-readable report',
' --quiet Skip the per-line colored dump',
' --profile-copy Copy the Yomitan profile to a scratch dir so this can run',
' while SubMiner is open (Electron locks the userData dir)',
' --config-dir <dir> SubMiner config dir (default: auto-detected)',
' --yomitan-user-data <dir> Electron userData dir holding the Yomitan profile',
' --yomitan-extension-path <dir>',
].join('\n');
}
const ANSI_RESET = '\u001b[0m';
function colorize(text: string, hex: string): string {
const normalized = hex.trim().replace(/^#/, '');
const expanded =
normalized.length === 3
? normalized
.split('')
.map((char) => `${char}${char}`)
.join('')
: normalized;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
return text;
}
const r = Number.parseInt(expanded.slice(0, 2), 16);
const g = Number.parseInt(expanded.slice(2, 4), 16);
const b = Number.parseInt(expanded.slice(4, 6), 16);
return `\u001b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
}
function formatTimestamp(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const mm = String(Math.floor(total / 60)).padStart(2, '0');
const ss = String(total % 60).padStart(2, '0');
return `${mm}:${ss}`;
}
// The user's real config dir is copied into a scratch dir so this read-only
// check can never rewrite config.jsonc (ConfigService migrates on load) or the
// live known-word cache (a --refresh persists tier data).
function createScratchState(configDir: string): { dir: string; cachePath: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-verify-'));
for (const fileName of ['config.jsonc', 'config.json']) {
const source = path.join(configDir, fileName);
if (fs.existsSync(source)) {
fs.copyFileSync(source, path.join(dir, fileName));
}
}
const cachePath = path.join(dir, 'known-words-cache.json');
const liveCachePath = path.join(configDir, 'known-words-cache.json');
if (fs.existsSync(liveCachePath)) {
fs.copyFileSync(liveCachePath, cachePath);
}
return { dir, cachePath };
}
// Electron locks a userData dir, so the Yomitan profile can't be shared with a
// running SubMiner. Copying the dictionary-bearing parts of the profile lets
// this check run mid-session (IndexedDB alone is often over 1 GB).
const YOMITAN_PROFILE_DIRS = [
'extensions',
'IndexedDB',
'Local Extension Settings',
'Local Storage',
];
function copyYomitanProfile(sourceUserDataPath: string): string {
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-profile-'));
for (const name of YOMITAN_PROFILE_DIRS) {
const source = path.join(sourceUserDataPath, name);
if (fs.existsSync(source)) {
fs.cpSync(source, path.join(target, name), { recursive: true });
}
}
return target;
}
function readPersistedCacheScope(cachePath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { scope?: unknown };
return typeof parsed.scope === 'string' ? parsed.scope : null;
} catch {
return null;
}
}
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
function createAnkiClient(url: string) {
const request = async (action: string, params: unknown): Promise<unknown> => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, version: 6, params }),
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as { result: unknown; error: string | null };
if (payload.error) {
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
}
return payload.result;
};
return {
request,
findNotes: (query: string) => request('findNotes', { query }),
notesInfo: (noteIds: number[]) => request('notesInfo', { notes: noteIds }),
};
}
function resolveTokenMatch(
token: MergedToken,
cache: KnownWordCacheManager,
matchMode: 'surface' | 'headword',
): { tier: KnownWordMaturityTier | null; noteIds: Set<number> } {
const matchText = resolveKnownWordText(token.surface, token.headword, matchMode);
const matchReading = resolveKnownWordReadingForMatch(token, matchMode);
const primaryTier = matchText ? cache.getKnownWordTier(matchText, matchReading) : null;
if (primaryTier) {
return { tier: primaryTier, noteIds: cache.getKnownWordMatchNoteIds(matchText, matchReading) };
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading || fallbackReading === matchText.trim()) {
return {
tier: null,
noteIds: matchText ? cache.getKnownWordMatchNoteIds(matchText, matchReading) : new Set(),
};
}
const fallbackOptions = { allowReadingOnlyMatch: false } as const;
return {
tier: cache.getKnownWordTier(fallbackReading, undefined, fallbackOptions),
noteIds: cache.getKnownWordMatchNoteIds(fallbackReading, undefined, fallbackOptions),
};
}
// Ground truth straight from card data, independent of the Anki search filters
// the cache refresh uses (prop:ivl / is:learn).
function classifyCardsIntoTier(
cards: Array<{ interval: number; queue: number; type: number }>,
thresholdDays: number,
): KnownWordMaturityTier {
if (cards.some((card) => card.interval >= thresholdDays)) return 'mature';
if (cards.some((card) => card.interval >= 1)) return 'young';
if (
cards.some((card) => card.type === 1 || card.type === 3 || card.queue === 1 || card.queue === 4)
)
return 'learning';
return 'new';
}
async function auditTokens(
reports: TokenReport[],
client: ReturnType<typeof createAnkiClient>,
thresholdDays: number,
): Promise<{ mismatches: AuditMismatch[]; auditedNotes: number }> {
const noteIds = [...new Set(reports.flatMap((report) => report.noteIds))];
const cardIdsByNote = new Map<number, number[]>();
for (let i = 0; i < noteIds.length; i += 500) {
const infos = (await client.notesInfo(noteIds.slice(i, i + 500))) as Array<{
noteId: number;
cards?: number[];
}>;
for (const info of infos) {
cardIdsByNote.set(info.noteId, info.cards ?? []);
}
}
const allCardIds = [...cardIdsByNote.values()].flat();
const cardById = new Map<number, { interval: number; queue: number; type: number }>();
for (let i = 0; i < allCardIds.length; i += 500) {
const infos = (await client.request('cardsInfo', {
cards: allCardIds.slice(i, i + 500),
})) as Array<{ cardId: number; interval: number; queue: number; type: number }>;
for (const info of infos) {
cardById.set(info.cardId, {
interval: info.interval,
queue: info.queue,
type: info.type,
});
}
}
const mismatches: AuditMismatch[] = [];
for (const report of reports) {
const cards = report.noteIds
.flatMap((noteId) => cardIdsByNote.get(noteId) ?? [])
.map((cardId) => cardById.get(cardId))
.filter((card): card is { interval: number; queue: number; type: number } => Boolean(card));
const liveTier = cards.length === 0 ? 'no-notes' : classifyCardsIntoTier(cards, thresholdDays);
if (liveTier !== report.tier) {
mismatches.push({
...report,
liveTier,
intervals: cards.map((card) => card.interval),
});
}
}
return { mismatches, auditedNotes: noteIds.length };
}
async function main(): Promise<void> {
const args = parseCliArgs(process.argv.slice(2));
let electronModule: typeof import('electron') | null = null;
let yomitanState: YomitanRuntimeState | null = null;
let scratchDir: string | null = null;
let profileCopyDir: string | null = null;
try {
const configDir =
args.configDir ??
resolveConfigDir({
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
existsSync: fs.existsSync,
});
const scratch = createScratchState(configDir);
scratchDir = scratch.dir;
const config = new ConfigService(scratch.dir).getConfig();
const ankiConfig = config.ankiConnect;
const matchMode = ankiConfig.knownWords?.matchMode === 'surface' ? 'surface' : 'headword';
const thresholdDays = getMatureIntervalThresholdDays(ankiConfig);
const client = createAnkiClient(ankiConfig.url);
const cacheScopeKey = getKnownWordCacheLifecycleConfig(ankiConfig);
const cache = new KnownWordCacheManager({
client: { findNotes: (query) => client.findNotes(query), notesInfo: client.notesInfo },
getConfig: () => ankiConfig,
knownWordCacheStatePath: scratch.cachePath,
showStatusNotification: () => {},
});
// A cache whose persisted scope key no longer matches the config is
// discarded on load, so every token would come back unknown.
if (!args.refresh && readPersistedCacheScope(scratch.cachePath) !== cacheScopeKey) {
process.stderr.write(
'warning: the persisted known-word cache was built under different settings and will be ' +
'ignored (the app refetches it on its next refresh). Re-run with --refresh to fetch tiers now.\n',
);
}
// startLifecycle loads the persisted cache; the refresh timer it arms is
// cleared before the event loop can run it.
cache.startLifecycle();
cache.stopLifecycle();
if (args.refresh) {
await cache.refresh(true);
}
const cues = parseSubtitleCues(fs.readFileSync(args.input, 'utf-8'), args.input);
const selectedCues = args.limit > 0 ? cues.slice(0, args.limit) : cues;
const mecabTokenizer = new MecabTokenizer();
if (!(await mecabTokenizer.checkAvailability())) {
throw new Error('MeCab is not available; tokenization would not match the overlay.');
}
electronModule = await loadElectronModule();
const userDataPath = args.profileCopy
? copyYomitanProfile(args.yomitanUserDataPath ?? configDir)
: (args.yomitanUserDataPath ?? configDir);
profileCopyDir = args.profileCopy ? userDataPath : null;
if (electronModule?.app && typeof electronModule.app.setPath === 'function') {
electronModule.app.setPath('userData', userDataPath);
}
yomitanState = await createYomitanRuntimeStateWithSearch(
userDataPath,
args.yomitanExtensionPath,
);
if (!yomitanState.available) {
throw new Error(`Yomitan tokenizer unavailable: ${yomitanState.note ?? 'unknown reason'}`);
}
const deps = createTokenizerDepsRuntime({
getYomitanExt: () => yomitanState!.yomitanExt as never,
getYomitanSession: () => yomitanState!.yomitanSession as never,
getYomitanParserWindow: () => yomitanState!.parserWindow as never,
setYomitanParserWindow: (window) => {
yomitanState!.parserWindow = window;
},
getYomitanParserReadyPromise: () => yomitanState!.parserReadyPromise as never,
setYomitanParserReadyPromise: (promise) => {
yomitanState!.parserReadyPromise = promise;
},
getYomitanParserInitPromise: () => yomitanState!.parserInitPromise as never,
setYomitanParserInitPromise: (promise) => {
yomitanState!.parserInitPromise = promise;
},
isKnownWord: (text, reading, options) => cache.isKnownWord(text, reading, options),
getKnownWordTier: (text, reading, options) => cache.getKnownWordTier(text, reading, options),
getKnownWordMatchMode: () => matchMode,
getKnownWordsEnabled: () => true,
// Other annotation layers are off so every colored token below is a
// known-word decision, not an N+1/frequency/name override.
getNPlusOneEnabled: () => false,
getNameMatchEnabled: () => false,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getJlptLevel: () => null,
getMecabTokenizer: () => ({ tokenize: (text: string) => mecabTokenizer.tokenize(text) }),
});
const styleColors = {
...FALLBACK_TIER_COLORS,
...(config.subtitleStyle?.knownWordMaturityColors ?? {}),
} as Record<KnownWordMaturityTier, string>;
const knownWordColor = config.subtitleStyle?.knownWordColor ?? '#a6da95';
const reports: TokenReport[] = [];
const tierCounts: Record<string, number> = {};
let knownTokens = 0;
let totalTokens = 0;
const lines: string[] = [];
for (const [cueIndex, cue] of selectedCues.entries()) {
const { text, tokens } = await withTimeout(
tokenizeSubtitle(cue.text, deps),
20_000,
`Tokenizer (cue ${cueIndex + 1})`,
);
if (!tokens || tokens.length === 0) {
continue;
}
let cursor = 0;
let rendered = '';
const ordered = [...tokens].sort((a, b) => (a.startPos ?? 0) - (b.startPos ?? 0));
for (const token of ordered) {
totalTokens += 1;
const start = Math.min(Math.max(0, token.startPos ?? 0), text.length);
const end = Math.min(Math.max(start, token.endPos ?? start), text.length);
if (start > cursor) {
rendered += text.slice(cursor, start);
}
const surfaceText = text.slice(start, end);
cursor = end;
if (!token.isKnown) {
rendered += surfaceText;
continue;
}
knownTokens += 1;
const tier: TierOrFallback = token.knownMaturity ?? 'known-no-tier';
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1;
rendered += colorize(
surfaceText,
tier === 'known-no-tier' ? knownWordColor : styleColors[tier],
);
reports.push({
cueIndex,
startTime: cue.startTime,
surface: token.surface,
headword: token.headword,
reading: token.reading,
tier,
noteIds: [...resolveTokenMatch(token, cache, matchMode).noteIds],
});
}
rendered += text.slice(cursor);
lines.push(`${formatTimestamp(cue.startTime)} ${rendered}`);
}
if (totalTokens === 0 && selectedCues.length > 0) {
throw new Error(
'Yomitan returned no tokens. SubMiner is probably running and holding the Electron ' +
'profile lock - quit it, or re-run with --profile-copy.',
);
}
let audit: { mismatches: AuditMismatch[]; auditedNotes: number } | null = null;
if (args.audit) {
audit = await auditTokens(reports, client, thresholdDays);
}
if (args.json) {
process.stdout.write(
`${JSON.stringify(
{
input: args.input,
cues: selectedCues.length,
totalTokens,
knownTokens,
tierCounts,
matureThresholdDays: thresholdDays,
matchMode,
tokens: reports,
audit,
},
null,
2,
)}\n`,
);
return;
}
if (!args.quiet) {
process.stdout.write(`${lines.join('\n')}\n\n`);
}
process.stdout.write(
[
`file : ${args.input}`,
`cues checked : ${selectedCues.length} of ${cues.length}`,
`tokens : ${totalTokens} (${knownTokens} known, ${totalTokens - knownTokens} unknown)`,
`match mode : ${matchMode} mature threshold: ${thresholdDays}d`,
'',
'known-token tiers:',
...TIERS.map(
(tier) =>
` ${colorize(tier.padEnd(9), styleColors[tier])} ${String(tierCounts[tier] ?? 0).padStart(5)}` +
` ${styleColors[tier]}`,
),
` ${colorize('no tier'.padEnd(9), knownWordColor)} ${String(tierCounts['known-no-tier'] ?? 0).padStart(5)} ${knownWordColor} (falls back to knownWordColor)`,
'',
].join('\n'),
);
if (audit) {
process.stdout.write(
`audit: ${reports.length - audit.mismatches.length}/${reports.length} highlighted tokens agree with live Anki card data ` +
`(${audit.auditedNotes} notes)\n`,
);
for (const mismatch of audit.mismatches.slice(0, 40)) {
process.stdout.write(
` ${formatTimestamp(mismatch.startTime)} ${mismatch.surface} (${mismatch.headword}) ` +
`shown=${mismatch.tier} live=${mismatch.liveTier} ivl=[${mismatch.intervals.join(', ')}] ` +
`notes=[${mismatch.noteIds.join(', ')}]\n`,
);
}
if (audit.mismatches.length > 40) {
process.stdout.write(` ... ${audit.mismatches.length - 40} more\n`);
}
}
} finally {
destroyParserWindow(yomitanState?.parserWindow ?? null);
for (const dir of [scratchDir, profileCopyDir]) {
if (dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
if (electronModule?.app) {
electronModule.app.quit();
}
}
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(`Error: ${(error as Error).message}`);
process.exit(1);
});
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveYomitanExtensionPath as resolveBuiltYomitanExtensionPath } from '../src/core/services/yomitan-extension-paths.js';
// Yomitan bootstrap for CLI scripts that need the app's real tokenizer. Mirrors
// what scripts/get_frequency.ts does inline; new scripts should import this.
export interface YomitanRuntimeState {
yomitanExt: unknown | null;
yomitanSession: unknown | null;
parserWindow: unknown | null;
parserReadyPromise: Promise<void> | null;
parserInitPromise: Promise<boolean> | null;
available: boolean;
note?: string;
}
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
export function destroyParserWindow(window: unknown): void {
if (!window || typeof window !== 'object') {
return;
}
const candidate = window as { isDestroyed?: () => boolean; destroy?: () => void };
if (typeof candidate.isDestroyed !== 'function' || typeof candidate.destroy !== 'function') {
return;
}
if (!candidate.isDestroyed()) {
candidate.destroy();
}
}
export async function loadElectronModule(): Promise<typeof import('electron') | null> {
try {
const electronImport = await import('electron');
return (electronImport.default ?? electronImport) as typeof import('electron');
} catch {
return null;
}
}
async function createYomitanRuntimeState(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const state: YomitanRuntimeState = {
yomitanExt: null,
yomitanSession: null,
parserWindow: null,
parserReadyPromise: null,
parserInitPromise: null,
available: false,
};
const electronImport = await loadElectronModule();
if (
!electronImport ||
!electronImport.app ||
typeof electronImport.app.whenReady !== 'function' ||
!electronImport.session
) {
state.note = electronImport
? 'electron runtime not available in this process'
: 'electron import failed';
return state;
}
try {
await electronImport.app.whenReady();
const loadYomitanExtension = (await import('../src/core/services/yomitan-extension-loader.js'))
.loadYomitanExtension as (options: {
userDataPath: string;
extensionPath?: string;
getYomitanParserWindow: () => unknown;
setYomitanParserWindow: (window: unknown) => void;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
setYomitanExtension: (extension: unknown) => void;
setYomitanSession: (session: unknown) => void;
}) => Promise<unknown>;
const extension = await loadYomitanExtension({
userDataPath,
extensionPath,
getYomitanParserWindow: () => state.parserWindow,
setYomitanParserWindow: (window) => {
state.parserWindow = window;
},
setYomitanParserReadyPromise: (promise) => {
state.parserReadyPromise = promise;
},
setYomitanParserInitPromise: (promise) => {
state.parserInitPromise = promise;
},
setYomitanExtension: (loaded) => {
state.yomitanExt = loaded;
},
setYomitanSession: (nextSession) => {
state.yomitanSession = nextSession;
},
});
if (!extension) {
state.note = 'yomitan extension is not available';
return state;
}
state.yomitanExt = extension;
state.available = true;
return state;
} catch (error) {
state.note = error instanceof Error ? error.message : 'failed to initialize yomitan extension';
return state;
}
}
export async function createYomitanRuntimeStateWithSearch(
userDataPath: string,
extensionPath?: string,
): Promise<YomitanRuntimeState> {
const resolvedExtensionPath = resolveBuiltYomitanExtensionPath({
explicitPath: extensionPath,
cwd: process.cwd(),
});
if (resolvedExtensionPath) {
try {
if (fs.existsSync(path.join(resolvedExtensionPath, 'manifest.json'))) {
const state = await createYomitanRuntimeState(userDataPath, resolvedExtensionPath);
if (!state.available && !state.note) {
state.note = `Failed to load yomitan extension at ${resolvedExtensionPath}`;
}
return state;
}
} catch {
// fall through to the unconstrained loader below
}
}
// No usable manifest at the resolved path, so let the loader search on its own.
return createYomitanRuntimeState(userDataPath);
}
+62
View File
@@ -1,5 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -465,6 +467,66 @@ test('AnkiIntegration does not allocate proxy server when proxy transport is dis
assert.equal(privateState.runtime.proxyServer, null);
});
test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => {
const occupiedServer = http.createServer();
occupiedServer.listen(0, '127.0.0.1');
await once(occupiedServer, 'listening');
const occupiedAddress = occupiedServer.address();
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-'));
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const integration = new AnkiIntegration(
{
enabled: true,
url: 'http://127.0.0.1:8765',
proxy: {
enabled: true,
host: '127.0.0.1',
port: occupiedAddress.port,
upstreamUrl: 'http://127.0.0.1:8765',
},
behavior: {
notificationType: 'overlay',
},
knownWords: {
highlightEnabled: false,
},
nPlusOne: {
enabled: false,
},
} as never,
{} as never,
{} as never,
undefined,
undefined,
undefined,
path.join(stateDir, 'known-words-cache.json'),
{},
undefined,
(payload) => {
overlayNotifications.push(payload as TestOverlayNotificationPayload);
},
);
try {
integration.start();
await integration.waitUntilReady();
assert.deepEqual(overlayNotifications, [
{
title: 'SubMiner',
body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
variant: 'info',
},
]);
} finally {
integration.stop();
occupiedServer.close();
await once(occupiedServer, 'close');
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
const integration = new AnkiIntegration(
{
+10
View File
@@ -28,6 +28,7 @@ import {
NotificationOptions,
} from './types/anki';
import { AiConfig } from './types/integrations';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
@@ -461,6 +462,7 @@ export class AnkiIntegration {
getDeck: () => this.config.deck,
findNotes: async (query, options) =>
(await this.client.findNotes(query, options)) as number[],
notifyUnavailable: (message) => this.showStatusNotification(message),
logInfo: (message, ...args) => log.info(message, ...args),
logWarn: (message, ...args) => log.warn(message, ...args),
logError: (message, ...args) => log.error(message, ...args),
@@ -731,6 +733,14 @@ export class AnkiIntegration {
return this.knownWordCache.isKnownWord(text, reading, options);
}
getKnownWordTier(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): KnownWordMaturityTier | null {
return this.knownWordCache.getKnownWordTier(text, reading, options);
}
getKnownWordMatchMode(): NPlusOneMatchMode {
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
}
@@ -543,3 +543,41 @@ test('proxy detects self-referential loop configuration', () => {
assert.equal(result, true);
});
test('proxy continues without a local listener when its address is already bound', async () => {
const occupiedServer = http.createServer();
occupiedServer.listen(0, '127.0.0.1');
await once(occupiedServer, 'listening');
const occupiedAddress = occupiedServer.address();
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
const info: string[] = [];
const warnings: string[] = [];
const proxy = new AnkiConnectProxyServer({
shouldAutoUpdateNewCards: () => true,
processNewCard: async () => undefined,
logInfo: (message) => info.push(message),
logWarn: (message, ...args) => warnings.push([message, ...args].join(' ')),
logError: () => undefined,
});
try {
proxy.start({
host: '127.0.0.1',
port: occupiedAddress.port,
upstreamUrl: 'http://127.0.0.1:8765',
});
await proxy.waitUntilReady();
assert.equal(proxy.isRunning, false);
assert.deepEqual(warnings, [
`[anki-proxy] Local proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
]);
proxy.stop();
assert.deepEqual(info, []);
} finally {
proxy.stop();
occupiedServer.close();
await once(occupiedServer, 'close');
}
});
@@ -27,6 +27,7 @@ export interface AnkiConnectProxyServerDeps {
logInfo: (message: string, ...args: unknown[]) => void;
logWarn: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
notifyUnavailable?: (message: string) => void;
}
export class AnkiConnectProxyServer {
@@ -78,7 +79,23 @@ export class AnkiConnectProxyServer {
void this.handleRequest(req, res, options.upstreamUrl);
});
const server = this.server;
this.server.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') {
this.resolveReady?.();
this.resolveReady = null;
this.rejectReady = null;
if (this.server === server) {
this.server = null;
}
this.deps.logWarn(
`[anki-proxy] Local proxy unavailable because http://${options.host}:${options.port} is already in use; continuing without it. Change ankiConnect.proxy.port or stop the process using that address.`,
);
this.deps.notifyUnavailable?.(
`AnkiConnect proxy unavailable because http://${options.host}:${options.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
);
return;
}
this.rejectReady?.(error as Error);
this.resolveReady = null;
this.rejectReady = null;
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
KnownWordCacheState,
knownWordsFromState,
parseKnownWordCacheState,
} from './known-word-cache-format';
function parseOrThrow(value: unknown): KnownWordCacheState {
const parsed = parseKnownWordCacheState(value);
assert.ok(parsed, 'expected the payload to parse');
return parsed;
}
const BASE = { refreshedAtMs: 1, scope: 'deck:test' };
test('known-word cache format reads words from every version the union covers', () => {
assert.deepEqual(
knownWordsFromState(parseOrThrow({ ...BASE, version: 1, words: ['する'] })),
new Set(['する']),
);
assert.deepEqual(
knownWordsFromState(
parseOrThrow({ ...BASE, version: 2, words: ['する'], notes: { '1': ['する'] } }),
),
new Set(['する']),
);
assert.deepEqual(
knownWordsFromState(
parseOrThrow({
...BASE,
version: 3,
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
}),
),
new Set(['する', '猫']),
);
// v4 only adds `tiers`; the words a reader sees must not change.
assert.deepEqual(
knownWordsFromState(
parseOrThrow({
...BASE,
version: 4,
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
tiers: { '1': 'mature', '2': 'young' },
}),
),
new Set(['する', '猫']),
);
});
test('known-word cache format rejects payloads that are not a known cache state', () => {
const notes = { '1': [{ word: 'する', reading: null }] };
assert.equal(parseKnownWordCacheState(null), null);
assert.equal(parseKnownWordCacheState('{}'), null);
// An unknown version must not be read as an empty cache.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 99, notes }), null);
assert.equal(
parseKnownWordCacheState({ version: 4, scope: 'deck:test', notes, tiers: {} }),
null,
);
assert.equal(parseKnownWordCacheState({ version: 4, refreshedAtMs: 1, notes, tiers: {} }), null);
// v4 without its tiers map is a v3 payload mislabelled as v4.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 4, notes }), null);
// v3 carries entry objects, not the bare strings v2 used.
assert.equal(parseKnownWordCacheState({ ...BASE, version: 3, notes: { '1': ['する'] } }), null);
});
@@ -0,0 +1,141 @@
// On-disk shape of the known-word cache, plus the only parser for it.
//
// Two processes read this file: the cache manager (which rebuilds its indexes
// from it) and the stats server (which counts known words). They used to carry
// separate hand-written parsers, so bumping the format to v4 for maturity tiers
// left the stats server silently reporting zero known words. Everything that
// touches the format now goes through here, and the version dispatch below ends
// in assertNever so adding a V5 to the union fails the build at every consumer
// instead of degrading to an empty result at runtime.
import type { KnownWordMaturityTier } from '../types/subtitle';
import type { KnownWordEntry } from './known-word-entries';
export interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
export interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
export interface KnownWordCacheStateV3 {
readonly version: 3;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
}
export interface KnownWordCacheStateV4 {
readonly version: 4;
readonly refreshedAtMs: number;
readonly scope: string;
readonly notes: Record<string, KnownWordEntry[]>;
readonly tiers: Record<string, KnownWordMaturityTier>;
}
export type KnownWordCacheState =
| KnownWordCacheStateV1
| KnownWordCacheStateV2
| KnownWordCacheStateV3
| KnownWordCacheStateV4;
// Version written by persistKnownWordCacheState. Readers accept every version
// in the union above; only the writer pins one.
export type CurrentKnownWordCacheState = KnownWordCacheStateV4;
// Exported so every consumer that switches on `version` can close its dispatch
// the same way: a new member of the union becomes a type error at each call
// site rather than a case that silently falls through.
export function assertNever(value: never): never {
throw new Error(`Unhandled known-word cache state: ${JSON.stringify(value)}`);
}
function isEntryRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isKnownWordEntry(entry: unknown): boolean {
if (!isEntryRecord(entry)) return false;
const candidate = entry as Partial<KnownWordEntry>;
return (
typeof candidate.word === 'string' &&
(candidate.reading === null || typeof candidate.reading === 'string')
);
}
// Returns the narrowed state, or null when the payload is not a cache state we
// recognize. Per-entry values that are merely unusable (an unknown maturity
// tier, a non-numeric note id) are dropped by callers at load time rather than
// rejecting the whole file.
export function parseKnownWordCacheState(value: unknown): KnownWordCacheState | null {
if (!isEntryRecord(value)) return null;
const candidate = value;
if (
candidate.version !== 1 &&
candidate.version !== 2 &&
candidate.version !== 3 &&
candidate.version !== 4
) {
return null;
}
if (typeof candidate.refreshedAtMs !== 'number') return null;
if (typeof candidate.scope !== 'string') return null;
if (candidate.version === 1 || candidate.version === 2) {
if (!Array.isArray(candidate.words)) return null;
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) return null;
}
if (candidate.version === 4) {
// Per-tier values are sanitized entry-by-entry at load time.
if (!isEntryRecord(candidate.tiers)) return null;
}
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
if (!isEntryRecord(candidate.notes)) return null;
const isValidNoteEntry =
candidate.version === 2
? (entry: unknown): boolean => typeof entry === 'string'
: isKnownWordEntry;
if (
!Object.values(candidate.notes).every(
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return null;
}
}
return candidate as unknown as KnownWordCacheState;
}
// Every word the cache considers known, flattened across notes. Consumers that
// only need membership (the stats server) use this instead of walking the
// version-specific layout themselves.
export function knownWordsFromState(state: KnownWordCacheState): Set<string> {
switch (state.version) {
case 1:
case 2:
return new Set(state.words);
case 3:
case 4: {
const words = new Set<string>();
for (const entries of Object.values(state.notes)) {
for (const entry of entries) {
if (entry.word) words.add(entry.word);
}
}
return words;
}
default:
return assertNever(state);
}
}
@@ -0,0 +1,411 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AnkiConnectConfig } from '../types/anki';
import { setLogLevel } from '../logger';
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';
interface HarnessNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
function createMaturityHarness(config: AnkiConnectConfig): {
manager: KnownWordCacheManager;
calls: { findNotes: number; notesInfo: number; queries: string[] };
statePath: string;
clientState: {
findNotesResult: number[];
notesInfoResult: HarnessNoteInfo[];
findNotesByQuery: Map<string, number[]>;
failedQueries: Set<string>;
};
createSiblingManager: () => KnownWordCacheManager;
cleanup: () => void;
} {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-'));
const statePath = path.join(stateDir, 'known-words-cache.json');
const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] };
const clientState = {
findNotesResult: [] as number[],
notesInfoResult: [] as HarnessNoteInfo[],
findNotesByQuery: new Map<string, number[]>(),
failedQueries: new Set<string>(),
};
const deps = {
client: {
findNotes: async (query: string) => {
calls.findNotes += 1;
calls.queries.push(query);
if (clientState.failedQueries.has(query)) {
throw new Error(`Anki unavailable for query: ${query}`);
}
if (clientState.findNotesByQuery.has(query)) {
return clientState.findNotesByQuery.get(query) ?? [];
}
return clientState.findNotesResult;
},
notesInfo: async (noteIds: number[]) => {
calls.notesInfo += 1;
return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
},
},
getConfig: () => config,
knownWordCacheStatePath: statePath,
showStatusNotification: () => undefined,
};
return {
manager: new KnownWordCacheManager(deps),
calls,
statePath,
clientState,
createSiblingManager: () => new KnownWordCacheManager(deps),
cleanup: () => {
fs.rmSync(stateDir, { recursive: true, force: true });
},
};
}
// The four queries a maturity refresh issues, in one place so a query-string
// change lands in a single spot.
function setTierQueries(
clientState: { findNotesByQuery: Map<string, number[]> },
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
): void {
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
}
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
deck: 'Mining',
fields: { word: 'Word' },
knownWords: {
highlightEnabled: true,
maturityEnabled: true,
refreshMinutes: 60,
},
...overrides,
};
}
test('lifecycle config key is unchanged when maturity is disabled', () => {
const disabled: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, refreshMinutes: 60 },
};
// Upgrading users keep their persisted cache: the key must not gain fields
// while maturity is off.
assert.equal(
getKnownWordCacheLifecycleConfig(disabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
);
const enabled: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
};
assert.equal(
getKnownWordCacheLifecycleConfig(enabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
);
const customThreshold: AnkiConnectConfig = {
knownWords: {
highlightEnabled: true,
maturityEnabled: true,
matureThresholdDays: 30,
refreshMinutes: 60,
},
};
assert.equal(
getKnownWordCacheLifecycleConfig(customThreshold),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
);
});
test('a cache built under the old tier rules is invalidated', () => {
const config: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
};
// v1 rules put lapsed cards in young because the interval queries did not
// exclude is:learn; those persisted tiers must not be served under v2.
assert.notEqual(
getKnownWordCacheLifecycleConfig(config),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
);
});
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
{ noteId: 3, fields: { Word: { value: '鳥' } } },
{ noteId: 4, fields: { Word: { value: '魚' } } },
];
await manager.refresh(true);
assert.equal(calls.findNotes, 4);
assert.equal(manager.getKnownWordTier('猫'), 'mature');
assert.equal(manager.getKnownWordTier('犬'), 'young');
assert.equal(manager.getKnownWordTier('鳥'), 'learning');
assert.equal(manager.getKnownWordTier('魚'), 'new');
assert.equal(manager.getKnownWordTier('馬'), null);
// Boolean matching still works alongside tiers.
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.isKnownWord('魚'), true);
} finally {
cleanup();
}
});
test('a note with cards in several tiers counts as its most mature card', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('猫'), 'mature');
} finally {
cleanup();
}
});
test('a word matched by several notes takes the most mature note tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '猫' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('猫'), 'young');
} finally {
cleanup();
}
});
test('tiers are reading-aware for words with several readings', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature');
assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning');
// No reading given: fail-open across readings, most mature wins.
assert.equal(manager.getKnownWordTier('床'), 'mature');
// Unknown reading for a reading-locked word: no match, no tier.
assert.equal(manager.getKnownWordTier('床', 'しょう'), null);
} finally {
cleanup();
}
});
test('reading-only fallback resolves tiers unless opted out', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
];
await manager.refresh(true);
assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
assert.equal(
manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
null,
);
} finally {
cleanup();
}
});
test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => {
const config = maturityConfig();
config.knownWords = { ...config.knownWords, maturityEnabled: false };
const { manager, calls, clientState, cleanup } = createMaturityHarness(config);
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(calls.findNotes, 1);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), null);
} finally {
cleanup();
}
});
test('refresh preserves known-word cache when maturity lookup fails', async () => {
const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig());
const originalInfo = console.info;
const infoLogs: string[] = [];
setLogLevel('info');
try {
console.info = (...args: unknown[]) => {
infoLogs.push(args.map((value) => String(value)).join(' '));
};
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn');
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true);
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), null);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
tiers: Record<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, {});
assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
cleanup();
}
});
test('tiers persist to v4 state and reload without refetching', async () => {
const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
createMaturityHarness(maturityConfig());
const originalDateNow = Date.now;
try {
Date.now = () => 120_000;
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
{ noteId: 2, fields: { Word: { value: '犬' } } },
];
await manager.refresh(true);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
version: number;
tiers: Record<string, string>;
};
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' });
const callsBeforeReload = calls.findNotes;
const reloaded = createSiblingManager();
reloaded.startLifecycle();
try {
assert.equal(reloaded.getKnownWordTier('猫'), 'mature');
assert.equal(reloaded.getKnownWordTier('犬'), 'learning');
assert.equal(calls.findNotes, callsBeforeReload);
} finally {
reloaded.stopLifecycle();
}
} finally {
Date.now = originalDateNow;
cleanup();
}
});
test('appendFromNoteInfo marks freshly mined notes as new tier', async () => {
const { manager, cleanup } = createMaturityHarness(maturityConfig());
try {
manager.appendFromNoteInfo({
noteId: 7,
fields: { Word: { value: '猫' } },
});
assert.equal(manager.isKnownWord('猫'), true);
assert.equal(manager.getKnownWordTier('猫'), 'new');
} finally {
cleanup();
}
});
test('appendFromNoteInfo preserves an existing maturity tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
clientState.notesInfoResult = [
{ noteId: 7, fields: { Word: { value: '猫' } } },
{ noteId: 8, fields: { Word: { value: '犬' } } },
];
await manager.refresh(true);
manager.appendFromNoteInfo({
noteId: 7,
fields: { Word: { value: '子猫' } },
});
manager.appendFromNoteInfo({
noteId: 8,
fields: { Word: { value: '子犬' } },
});
assert.equal(manager.getKnownWordTier('子猫'), 'mature');
assert.equal(manager.getKnownWordTier('子犬'), 'learning');
} finally {
cleanup();
}
});
test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
try {
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
{ noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
];
await manager.refresh(true);
// Same matching rules as getKnownWordTier, so an audit can re-derive the
// rendered tier from the exact notes that produced it.
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]);
assert.deepEqual(
[
...manager.getKnownWordMatchNoteIds('けいこく', undefined, {
allowReadingOnlyMatch: false,
}),
],
[],
);
assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []);
} finally {
cleanup();
}
});
@@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
version: number;
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
};
assert.equal(persisted.version, 3);
assert.equal(persisted.version, 4);
assert.deepEqual(persisted.notes, {
'1': [{ word: '鳥', reading: null }],
});
+259 -144
View File
@@ -4,7 +4,22 @@ import path from 'path';
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getConfiguredWordFieldName } from '../anki-field-config';
import { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle';
import { createLogger } from '../logger';
import {
KNOWN_WORD_MATURITY_RULES_VERSION,
classifyKnownWordNoteTier,
fetchKnownWordMaturityTierSets,
getKnownWordMaturityEnabled,
getMatureIntervalThresholdDays,
maxKnownWordMaturityTier,
sanitizeKnownWordMaturityTier,
} from './known-word-maturity';
import {
CurrentKnownWordCacheState,
assertNever,
parseKnownWordCacheState,
} from './known-word-cache-format';
import {
DEFAULT_KNOWN_WORD_READING_FIELDS,
KnownWordEntry,
@@ -62,11 +77,21 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
}
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
return JSON.stringify({
const payload: Record<string, unknown> = {
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
scope: getKnownWordCacheScopeForConfig(config),
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
});
};
// The maturity fields are only added while enabled so persisted caches from
// before the feature existed (or with it off) keep their identity.
// maturityRules is the classification-rule version: bump it whenever the tier
// queries change meaning so existing caches refetch instead of serving tiers
// computed under the old rules.
if (getKnownWordMaturityEnabled(config)) {
payload.maturity = getMatureIntervalThresholdDays(config);
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
}
return JSON.stringify(payload);
}
export interface KnownWordCacheNoteInfo {
@@ -74,30 +99,6 @@ export interface KnownWordCacheNoteInfo {
fields: Record<string, { value: string }>;
}
interface KnownWordCacheStateV1 {
readonly version: 1;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
}
interface KnownWordCacheStateV2 {
readonly version: 2;
readonly refreshedAtMs: number;
readonly scope: string;
readonly words: string[];
readonly notes: Record<string, string[]>;
}
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 {
@@ -125,12 +126,13 @@ type KnownWordQueryScope = {
export class KnownWordCacheManager {
private knownWordsLastRefreshedAtMs = 0;
private knownWordsStateKey = '';
// word → (hiragana reading | NO_READING_KEY → note count). NO_READING_KEY
// word → (hiragana reading | NO_READING_KEY → note ids). 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 wordReadingNoteIds = new Map<string, Map<string, Set<number>>>();
// hiragana reading → note ids, so kana tokens still match by reading alone.
private readingNoteIds = new Map<string, Set<number>>();
private noteEntriesById = new Map<number, KnownWordEntry[]>();
private noteTierById = new Map<number, KnownWordMaturityTier>();
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
private isRefreshingKnownWords = false;
@@ -156,7 +158,7 @@ export class KnownWordCacheManager {
return false;
}
const knownReadings = this.wordReadingCounts.get(normalized);
const knownReadings = this.wordReadingNoteIds.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
@@ -168,7 +170,7 @@ export class KnownWordCacheManager {
}
// Callers that look up a kanji token's reading (not subtitle text) must
// opt out of the reading-only fallback: readingCounts holds readings of
// opt out of the reading-only fallback: readingNoteIds holds readings of
// every note including kanji words, so 渓谷's けいこく would match a
// mined 警告/けいこく.
if (options?.allowReadingOnlyMatch === false) {
@@ -182,7 +184,86 @@ export class KnownWordCacheManager {
if ([...hiragana].length === 1) {
return false;
}
return this.readingCounts.has(hiragana);
return this.readingNoteIds.has(hiragana);
}
// Maturity tier for a matching known word, following the exact matching
// rules of isKnownWord. A match with no tier data (tier fetch failed or
// pre-v4 cache) returns null so rendering falls back to the single
// known-word color.
getKnownWordTier(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): KnownWordMaturityTier | null {
if (!getKnownWordMaturityEnabled(this.deps.getConfig())) {
return null;
}
return this.maxTierForNotes(null, this.getKnownWordMatchNoteIds(text, reading, options));
}
// Note ids a known-word lookup matches, using the same matching rules as
// getKnownWordTier. Exposed for diagnostics (see
// scripts/verify-known-word-highlights.ts), which audits a rendered tier
// against the live card data of the notes that produced it.
getKnownWordMatchNoteIds(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): Set<number> {
const matches = new Set<number>();
const normalized = this.normalizeKnownWordForLookup(text);
if (normalized.length === 0) {
return matches;
}
const knownReadings = this.wordReadingNoteIds.get(normalized);
if (knownReadings && knownReadings.size > 0) {
const normalizedReading =
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
if (normalizedReading.length === 0) {
for (const noteIds of knownReadings.values()) {
for (const noteId of noteIds) {
matches.add(noteId);
}
}
return matches;
}
for (const key of [NO_READING_KEY, normalizedReading]) {
for (const noteId of knownReadings.get(key) ?? []) {
matches.add(noteId);
}
}
return matches;
}
if (options?.allowReadingOnlyMatch === false) {
return matches;
}
const hiragana = convertKatakanaToHiragana(normalized);
if ([...hiragana].length === 1) {
return matches;
}
for (const noteId of this.readingNoteIds.get(hiragana) ?? []) {
matches.add(noteId);
}
return matches;
}
private maxTierForNotes(
current: KnownWordMaturityTier | null,
noteIds: ReadonlySet<number>,
): KnownWordMaturityTier | null {
let tier = current;
for (const noteId of noteIds) {
tier = maxKnownWordMaturityTier(tier, this.noteTierById.get(noteId) ?? null);
if (tier === 'mature') {
break;
}
}
return tier;
}
refresh(force = false): Promise<void> {
@@ -229,7 +310,7 @@ export class KnownWordCacheManager {
let didMutateCache = false;
const currentStateKey = this.getKnownWordCacheStateKey();
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
didMutateCache = this.wordReadingCounts.size > 0 || this.noteEntriesById.size > 0;
didMutateCache = this.wordReadingNoteIds.size > 0 || this.noteEntriesById.size > 0;
this.clearKnownWordCacheState();
}
if (!this.knownWordsStateKey) {
@@ -247,6 +328,15 @@ export class KnownWordCacheManager {
return didMutateCache;
}
// A just-mined card has never been reviewed.
if (
this.isMaturityTrackingEnabled() &&
this.noteEntriesById.has(noteInfo.noteId) &&
!this.noteTierById.has(noteInfo.noteId)
) {
this.noteTierById.set(noteInfo.noteId, 'new');
}
if (this.knownWordsLastRefreshedAtMs <= 0) {
this.knownWordsLastRefreshedAtMs = Date.now();
}
@@ -290,6 +380,21 @@ export class KnownWordCacheManager {
this.isRefreshingKnownWords = true;
try {
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
const maturityTrackingEnabled = this.isMaturityTrackingEnabled();
let maturityFetchFailed = false;
let tierSets = null;
if (maturityTrackingEnabled) {
try {
tierSets = await fetchKnownWordMaturityTierSets(
(query, options) => this.deps.client.findNotes(query, options),
this.getKnownWordQueryScopes().map((scope) => scope.query),
getMatureIntervalThresholdDays(this.deps.getConfig()),
);
} catch (error) {
maturityFetchFailed = true;
log.warn('Failed to fetch known-word maturity tiers:', (error as Error).message);
}
}
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
if (this.noteEntriesById.size === 0) {
@@ -316,13 +421,25 @@ export class KnownWordCacheManager {
}
}
this.noteTierById = new Map();
if (tierSets) {
for (const noteId of currentNoteIds) {
this.noteTierById.set(noteId, classifyKnownWordNoteTier(noteId, tierSets));
}
}
this.knownWordsLastRefreshedAtMs = Date.now();
this.knownWordsStateKey = frozenStateKey;
this.persistKnownWordCacheState();
log.info(
'Known-word cache refreshed',
`noteCount=${currentNoteIds.length}`,
`wordCount=${this.wordReadingCounts.size}`,
`wordCount=${this.wordReadingNoteIds.size}`,
tierSets
? `maturityTiers=${this.noteTierById.size}`
: maturityFetchFailed
? 'maturityTiers=fetch-failed'
: 'maturityTiers=off',
);
} catch (error) {
log.warn('Failed to refresh known-word cache:', (error as Error).message);
@@ -337,6 +454,10 @@ export class KnownWordCacheManager {
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
}
private isMaturityTrackingEnabled(): boolean {
return getKnownWordMaturityEnabled(this.deps.getConfig());
}
private shouldAddMinedWordsImmediately(): boolean {
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
}
@@ -593,12 +714,13 @@ export class KnownWordCacheManager {
return false;
}
this.removeEntriesFromCounts(previousEntries);
this.removeEntriesFromIndexes(noteId, previousEntries);
if (normalizedEntries.length > 0) {
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries);
} else {
this.noteEntriesById.delete(noteId);
this.noteTierById.delete(noteId);
}
return true;
}
@@ -609,54 +731,68 @@ export class KnownWordCacheManager {
return;
}
this.noteEntriesById.delete(noteId);
this.removeEntriesFromCounts(previousEntries);
this.noteTierById.delete(noteId);
this.removeEntriesFromIndexes(noteId, previousEntries);
}
private addEntriesToCounts(entries: KnownWordEntry[]): void {
private addEntriesToIndexes(noteId: number, entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
let readings = this.wordReadingCounts.get(entry.word);
let readings = this.wordReadingNoteIds.get(entry.word);
if (!readings) {
readings = new Map();
this.wordReadingCounts.set(entry.word, readings);
this.wordReadingNoteIds.set(entry.word, readings);
}
readings.set(readingKey, (readings.get(readingKey) ?? 0) + 1);
let noteIds = readings.get(readingKey);
if (!noteIds) {
noteIds = new Set();
readings.set(readingKey, noteIds);
}
noteIds.add(noteId);
if (entry.reading) {
this.readingCounts.set(entry.reading, (this.readingCounts.get(entry.reading) ?? 0) + 1);
let readingNotes = this.readingNoteIds.get(entry.reading);
if (!readingNotes) {
readingNotes = new Set();
this.readingNoteIds.set(entry.reading, readingNotes);
}
readingNotes.add(noteId);
}
}
}
private removeEntriesFromCounts(entries: KnownWordEntry[]): void {
private removeEntriesFromIndexes(noteId: number, entries: KnownWordEntry[]): void {
for (const entry of entries) {
const readingKey = entry.reading ?? NO_READING_KEY;
const readings = this.wordReadingCounts.get(entry.word);
const readings = this.wordReadingNoteIds.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);
const noteIds = readings.get(readingKey);
if (noteIds) {
noteIds.delete(noteId);
if (noteIds.size === 0) {
readings.delete(readingKey);
if (readings.size === 0) {
this.wordReadingNoteIds.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);
const readingNotes = this.readingNoteIds.get(entry.reading);
if (readingNotes) {
readingNotes.delete(noteId);
if (readingNotes.size === 0) {
this.readingNoteIds.delete(entry.reading);
}
}
}
}
}
private clearInMemoryState(): void {
this.wordReadingCounts = new Map();
this.readingCounts = new Map();
this.wordReadingNoteIds = new Map();
this.readingNoteIds = new Map();
this.noteEntriesById = new Map();
this.noteTierById = new Map();
this.knownWordsLastRefreshedAtMs = 0;
}
@@ -675,8 +811,8 @@ export class KnownWordCacheManager {
return;
}
const parsed = JSON.parse(raw) as unknown;
if (!this.isKnownWordCacheStateValid(parsed)) {
const parsed = parseKnownWordCacheState(JSON.parse(raw) as unknown);
if (!parsed) {
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
@@ -689,48 +825,63 @@ 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;
switch (parsed.version) {
case 1:
// v1 has no per-note snapshots to convert; refetch from Anki.
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
return;
case 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 normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({
word: this.normalizeKnownWordForLookup(word),
reading: null,
})),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToIndexes(noteId, normalizedEntries);
}
const normalizedEntries = normalizeKnownWordEntryList(entries);
if (normalizedEntries.length === 0) {
continue;
this.knownWordsStateKey = parsed.scope;
return;
case 3:
case 4:
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.addEntriesToIndexes(noteId, normalizedEntries);
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
if (parsed.version === 4) {
for (const [noteIdKey, tier] of Object.entries(parsed.tiers)) {
const noteId = Number.parseInt(noteIdKey, 10);
const sanitizedTier = sanitizeKnownWordMaturityTier(tier);
if (sanitizedTier && this.noteEntriesById.has(noteId)) {
this.noteTierById.set(noteId, sanitizedTier);
}
}
}
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
this.knownWordsStateKey = parsed.scope;
return;
default:
assertNever(parsed);
}
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 normalizedEntries = normalizeKnownWordEntryList(
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
);
if (normalizedEntries.length === 0) {
continue;
}
this.noteEntriesById.set(noteId, normalizedEntries);
this.addEntriesToCounts(normalizedEntries);
}
this.knownWordsStateKey = parsed.scope;
return;
}
// 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();
@@ -741,17 +892,23 @@ export class KnownWordCacheManager {
private persistKnownWordCacheState(): void {
try {
const notes: Record<string, KnownWordEntry[]> = {};
const tiers: Record<string, KnownWordMaturityTier> = {};
for (const [noteId, entries] of this.noteEntriesById.entries()) {
if (entries.length > 0) {
notes[String(noteId)] = entries;
const tier = this.noteTierById.get(noteId);
if (tier) {
tiers[String(noteId)] = tier;
}
}
}
const state: KnownWordCacheStateV3 = {
version: 3,
const state: CurrentKnownWordCacheState = {
version: 4,
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
scope: this.knownWordsStateKey,
notes,
tiers,
};
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
} catch (error) {
@@ -759,48 +916,6 @@ 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 && candidate.version !== 3) {
return false;
}
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 ||
Array.isArray(candidate.notes)
) {
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(
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
)
) {
return false;
}
}
return true;
}
private extractKnownWordEntriesFromNoteInfo(
noteInfo: KnownWordCacheNoteInfo,
preferredFields = this.getConfiguredFields(),
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnkiConnectConfig } from '../types/anki';
import {
DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS,
buildKnownWordMaturityTierQueries,
classifyKnownWordNoteTier,
getKnownWordMaturityEnabled,
getMatureIntervalThresholdDays,
maxKnownWordMaturityTier,
sanitizeKnownWordMaturityTier,
} from './known-word-maturity';
function makeConfig(knownWords: AnkiConnectConfig['knownWords']): AnkiConnectConfig {
return { url: 'http://127.0.0.1:8765', knownWords } as AnkiConnectConfig;
}
test('maturity is enabled only when both highlight and maturity flags are on', () => {
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: true })),
true,
);
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: false, maturityEnabled: true })),
false,
);
assert.equal(
getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true, maturityEnabled: false })),
false,
);
assert.equal(getKnownWordMaturityEnabled(makeConfig({ highlightEnabled: true })), false);
assert.equal(getKnownWordMaturityEnabled(makeConfig(undefined)), false);
});
test('mature threshold falls back to default for invalid values', () => {
assert.equal(DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS, 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 30 })), 30);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 14.9 })), 14);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: 0 })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: -5 })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({ matureThresholdDays: Number.NaN })), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig({})), 21);
assert.equal(getMatureIntervalThresholdDays(makeConfig(undefined)), 21);
});
test('tier queries append Anki search props to a deck scope query', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21 -is:learn');
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn');
assert.equal(queries.learning, 'deck:"Mining" is:learn');
});
test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
// A lapsed card keeps an interval of at least the lapse minInt (>= 1), so
// without the exclusion the young query would claim every relearning card
// and the learning tier could only ever match brand-new cards mid-step.
for (const intervalQuery of [queries.mature, queries.young]) {
assert.ok(intervalQuery.includes('-is:learn'));
}
assert.equal(queries.learning, 'deck:"Mining" is:learn');
});
test('tier queries with an empty scope query have no leading space', () => {
const queries = buildKnownWordMaturityTierQueries('', 30);
assert.equal(queries.mature, 'prop:ivl>=30 -is:learn');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn');
assert.equal(queries.learning, 'is:learn');
});
test('note classification picks the most mature matching tier', () => {
const sets = {
mature: new Set([1, 4]),
young: new Set([2, 4]),
learning: new Set([3, 4, 2]),
};
assert.equal(classifyKnownWordNoteTier(1, sets), 'mature');
assert.equal(classifyKnownWordNoteTier(2, sets), 'young');
assert.equal(classifyKnownWordNoteTier(3, sets), 'learning');
// Note with mature, young, and learning cards: most mature card wins.
assert.equal(classifyKnownWordNoteTier(4, sets), 'mature');
assert.equal(classifyKnownWordNoteTier(99, sets), 'new');
});
test('maxKnownWordMaturityTier picks the higher tier and tolerates null', () => {
assert.equal(maxKnownWordMaturityTier('mature', 'new'), 'mature');
assert.equal(maxKnownWordMaturityTier('learning', 'young'), 'young');
assert.equal(maxKnownWordMaturityTier('new', null), 'new');
assert.equal(maxKnownWordMaturityTier(null, 'learning'), 'learning');
assert.equal(maxKnownWordMaturityTier(null, null), null);
assert.equal(maxKnownWordMaturityTier(undefined, undefined), null);
});
test('sanitizeKnownWordMaturityTier accepts only valid tiers', () => {
assert.equal(sanitizeKnownWordMaturityTier('mature'), 'mature');
assert.equal(sanitizeKnownWordMaturityTier('young'), 'young');
assert.equal(sanitizeKnownWordMaturityTier('learning'), 'learning');
assert.equal(sanitizeKnownWordMaturityTier('new'), 'new');
assert.equal(sanitizeKnownWordMaturityTier('MATURE'), null);
assert.equal(sanitizeKnownWordMaturityTier(''), null);
assert.equal(sanitizeKnownWordMaturityTier(21), null);
assert.equal(sanitizeKnownWordMaturityTier(null), null);
assert.equal(sanitizeKnownWordMaturityTier(undefined), null);
});
+112
View File
@@ -0,0 +1,112 @@
import type { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle';
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
// Version of the tier classification rules; part of the known-word cache
// identity so a rule change invalidates caches built under the old rules.
export const KNOWN_WORD_MATURITY_RULES_VERSION = 2;
// Ascending maturity; index order backs tier comparison.
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
export interface KnownWordMaturityTierQueries {
mature: string;
young: string;
learning: string;
}
export interface KnownWordMaturityTierSets {
mature: ReadonlySet<number>;
young: ReadonlySet<number>;
learning: ReadonlySet<number>;
}
// Maturity tiers only affect how known-word highlights render, so both flags
// must be on before tier data is fetched or served.
export function getKnownWordMaturityEnabled(config: AnkiConnectConfig): boolean {
return (
config.knownWords?.highlightEnabled === true && config.knownWords?.maturityEnabled === true
);
}
export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): number {
const threshold = config.knownWords?.matureThresholdDays;
if (typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 1) {
return Math.floor(threshold);
}
return DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS;
}
// Anki search props classify notes server-side: a note matches a tier query
// when ANY of its cards matches, which implements most-mature-card-wins for
// free once tiers are checked in mature > young > learning order.
//
// The interval tiers exclude is:learn so the per-card buckets stay disjoint and
// match Anki's own card counts, where (re)learning is its own bucket rather
// than part of young/mature. Without the exclusion a lapsed card - whose
// interval is reset to at least lapse minInt, so >= 1 - is caught by the young
// query first and the learning tier becomes unreachable in practice.
export function buildKnownWordMaturityTierQueries(
scopeQuery: string,
thresholdDays: number,
): KnownWordMaturityTierQueries {
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
return {
mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
learning: `${prefix}is:learn`,
};
}
export async function fetchKnownWordMaturityTierSets(
findNotes: (query: string, options?: { maxRetries?: number }) => Promise<unknown>,
scopeQueries: string[],
thresholdDays: number,
): Promise<{ mature: Set<number>; young: Set<number>; learning: Set<number> }> {
const sets = {
mature: new Set<number>(),
young: new Set<number>(),
learning: new Set<number>(),
};
for (const scopeQuery of scopeQueries) {
const queries = buildKnownWordMaturityTierQueries(scopeQuery, thresholdDays);
for (const tier of ['mature', 'young', 'learning'] as const) {
const noteIds = (await findNotes(queries[tier], { maxRetries: 0 })) as number[];
if (!Array.isArray(noteIds)) {
continue;
}
for (const noteId of noteIds) {
if (Number.isInteger(noteId) && noteId > 0) {
sets[tier].add(noteId);
}
}
}
}
return sets;
}
export function classifyKnownWordNoteTier(
noteId: number,
sets: KnownWordMaturityTierSets,
): KnownWordMaturityTier {
if (sets.mature.has(noteId)) return 'mature';
if (sets.young.has(noteId)) return 'young';
if (sets.learning.has(noteId)) return 'learning';
return 'new';
}
export function maxKnownWordMaturityTier(
a: KnownWordMaturityTier | null | undefined,
b: KnownWordMaturityTier | null | undefined,
): KnownWordMaturityTier | null {
if (!a) return b ?? null;
if (!b) return a;
return TIER_ORDER.indexOf(a) >= TIER_ORDER.indexOf(b) ? a : b;
}
export function sanitizeKnownWordMaturityTier(value: unknown): KnownWordMaturityTier | null {
return typeof value === 'string' && TIER_ORDER.includes(value as KnownWordMaturityTier)
? (value as KnownWordMaturityTier)
: null;
}
+14 -2
View File
@@ -112,8 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
if (!key) continue;
const valueNode = propertyValue(property);
const value = valueNode ? getNodeValue(valueNode) : undefined;
if (key === 'enabled' || key === 'minSentenceWords') {
canonicalNPlusOneValues.set(key, value);
if (key === 'enabled') {
if (typeof value === 'boolean') {
canonicalNPlusOneValues.set(key, value);
} else {
canonicalNPlusOneValues.delete(key);
}
continue;
}
if (key === 'minSentenceWords') {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
canonicalNPlusOneValues.set(key, value);
} else {
canonicalNPlusOneValues.delete(key);
}
continue;
}
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
+32
View File
@@ -2182,6 +2182,7 @@ test('runtime options registry is centralized', () => {
assert.deepEqual(ids, [
'anki.autoUpdateNewCards',
'subtitle.annotation.knownWords.highlightEnabled',
'subtitle.annotation.knownWords.maturityEnabled',
'subtitle.annotation.nPlusOne',
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
@@ -2462,6 +2463,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config',
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('later invalid duplicate nPlusOne values supersede earlier valid values', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
const originalContent = `{
"ankiConnect": {
"nPlusOne": {
"enabled": true,
"minSentenceWords": 4
},
"nPlusOne": {
"enabled": "yes",
"minSentenceWords": "4"
}
}
}`;
fs.writeFileSync(configPath, originalContent, 'utf-8');
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.nPlusOne.enabled, DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled);
assert.equal(
config.ankiConnect.nPlusOne.minSentenceWords,
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.minSentenceWords'));
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
});
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -60,6 +60,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
},
knownWords: {
highlightEnabled: false,
maturityEnabled: false,
matureThresholdDays: 21,
refreshMinutes: 1440,
addMinedWordsImmediately: true,
matchMode: 'headword',
@@ -32,6 +32,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
backdropFilter: 'blur(6px)',
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
@@ -295,6 +295,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.',
},
{
path: 'ankiConnect.knownWords.maturityEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
description:
'Color known-word highlights by Anki card maturity (new, learning, young, mature) instead of a single color. Requires known-word highlighting.',
},
{
path: 'ankiConnect.knownWords.matureThresholdDays',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.knownWords.matureThresholdDays,
description:
'Card interval in days at which a known word counts as mature (Anki convention: 21).',
},
{
path: 'ankiConnect.knownWords.refreshMinutes',
kind: 'number',
@@ -109,6 +109,34 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
description: 'Color used for the single N+1 target token subtitle highlight.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.new',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.new,
description:
'Color for known words whose Anki cards are new (never reviewed), when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.learning',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.learning,
description:
'Color for known words whose Anki cards are in (re)learning, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.young',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.young,
description:
'Color for known words whose Anki cards are in review below the mature threshold, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.knownWordMaturityColors.mature',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.knownWordMaturityColors.mature,
description:
'Color for known words whose Anki cards are at or above the mature interval threshold, when maturity highlighting is enabled.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
+16
View File
@@ -35,6 +35,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'subtitle.annotation.knownWords.maturityEnabled',
path: 'ankiConnect.knownWords.maturityEnabled',
label: 'Known Word Maturity Colors',
scope: 'subtitle',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.knownWords.maturityEnabled,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
knownWords: {
maturityEnabled: value === true,
},
}),
},
{
id: 'subtitle.annotation.nPlusOne',
path: 'ankiConnect.nPlusOne.enabled',
+170
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
import { createWarningCollector } from '../warnings';
import { applyAnkiConnectResolution } from './anki-connect';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import type { ResolveContext } from './context';
function makeContext(ankiConnect: unknown): {
@@ -39,6 +40,90 @@ test('modern invalid knownWords.highlightEnabled warns modern key and does not f
);
});
test('invalid modern known-words primitive values warn and keep defaults', () => {
const { context, warnings } = makeContext({
knownWords: {
refreshMinutes: 'daily',
matchMode: false,
},
nPlusOne: {
minSentenceWords: 'three',
},
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
);
assert.equal(
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.knownWords.matchMode',
],
);
});
test('invalid legacy known-words primitive values warn and keep defaults', () => {
const { context, warnings } = makeContext({
behavior: {
nPlusOneHighlightEnabled: 'yes',
nPlusOneRefreshMinutes: 'daily',
nPlusOneMatchMode: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.highlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.refreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
],
);
});
test('known-words resolution can run independently from other Anki domains', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true },
proxy: { port: -1 },
});
const ankiConnect = context.src.ankiConnect as Record<string, unknown>;
applyAnkiKnownWordsResolution(context, ankiConnect, {});
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(
warnings.some((warning) => warning.path.startsWith('ankiConnect.proxy')),
false,
);
});
test('normalizes ankiConnect tags by trimming and deduping', () => {
const { context, warnings } = makeContext({
tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
@@ -177,6 +262,40 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
);
});
test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
const { context, warnings } = makeContext({
fields: { word: 7 },
media: { generateAudio: 'yes' },
behavior: { overwriteAudio: 'yes' },
metadata: { pattern: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.word, DEFAULT_CONFIG.ankiConnect.fields.word);
assert.equal(
context.resolved.ankiConnect.media.generateAudio,
DEFAULT_CONFIG.ankiConnect.media.generateAudio,
);
assert.equal(
context.resolved.ankiConnect.behavior.overwriteAudio,
DEFAULT_CONFIG.ankiConnect.behavior.overwriteAudio,
);
assert.equal(
context.resolved.ankiConnect.metadata.pattern,
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.fields.word',
'ankiConnect.media.generateAudio',
'ankiConnect.behavior.overwriteAudio',
'ankiConnect.metadata.pattern',
],
);
});
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
const { context, warnings } = makeContext({
wordField: 'TargetWordLegacy',
@@ -209,3 +328,54 @@ test('warns and falls back for invalid proxy settings', () => {
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port'));
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
});
test('resolves knownWords maturity settings from config', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true, maturityEnabled: true, matureThresholdDays: 30 },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.maturityEnabled, true);
assert.equal(context.resolved.ankiConnect.knownWords.matureThresholdDays, 30);
assert.deepEqual(warnings, []);
});
test('warns and falls back for invalid knownWords maturity settings', () => {
const { context, warnings } = makeContext({
knownWords: { maturityEnabled: 'yes', matureThresholdDays: 0 },
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.maturityEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
);
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.maturityEnabled'));
assert.ok(
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matureThresholdDays'),
);
});
test('omitted knownWords maturity settings fall back to defaults', () => {
const { context, warnings } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(
context.resolved.ankiConnect.knownWords.maturityEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
);
assert.equal(
context.resolved.ankiConnect.knownWords.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
);
assert.deepEqual(warnings, []);
});
+16 -943
View File
@@ -1,952 +1,25 @@
import { DEFAULT_CONFIG } from '../definitions';
import type { ResolveContext } from './context';
import { isNotificationType, type NotificationType } from '../../types/notification';
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
function asNotificationType(value: unknown): NotificationType | undefined {
return isNotificationType(value) ? value : undefined;
}
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
import { applyAnkiModernResolution } from './anki-connect/modern';
import { isObject } from './shared';
export function applyAnkiConnectResolution(context: ResolveContext): void {
if (!isObject(context.src.ankiConnect)) {
return;
}
const ac = context.src.ankiConnect;
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
const proxy = isObject(ac.proxy) ? (ac.proxy as Record<string, unknown>) : {};
const legacyKeys = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
const hasOwn = (obj: Record<string, unknown>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const ankiConnect = context.src.ankiConnect;
const behavior = isObject(ankiConnect.behavior) ? ankiConnect.behavior : {};
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ac as Record<string, unknown>;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !legacyKeys.has(key)),
);
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
...(isObject(ac.fields)
? (ac.fields as (typeof context.resolved)['ankiConnect']['fields'])
: {}),
},
media: {
...context.resolved.ankiConnect.media,
...(isObject(ac.media)
? (ac.media as (typeof context.resolved)['ankiConnect']['media'])
: {}),
},
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: {
...context.resolved.ankiConnect.behavior,
...(isObject(ac.behavior)
? (ac.behavior as (typeof context.resolved)['ankiConnect']['behavior'])
: {}),
},
proxy: {
...context.resolved.ankiConnect.proxy,
},
metadata: {
...context.resolved.ankiConnect.metadata,
...(isObject(ac.metadata)
? (ac.metadata as (typeof context.resolved)['ankiConnect']['metadata'])
: {}),
},
isLapis: {
...context.resolved.ankiConnect.isLapis,
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ac.isKiku)
? (ac.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
};
if (hasOwn(media, 'mirrorMpvVolume')) {
const parsed = asBoolean(media.mirrorMpvVolume);
if (parsed === undefined) {
context.resolved.ankiConnect.media.mirrorMpvVolume =
DEFAULT_CONFIG.ankiConnect.media.mirrorMpvVolume;
context.warn(
'ankiConnect.media.mirrorMpvVolume',
media.mirrorMpvVolume,
context.resolved.ankiConnect.media.mirrorMpvVolume,
'Expected boolean.',
);
} else {
context.resolved.ankiConnect.media.mirrorMpvVolume = parsed;
}
}
if (hasOwn(behavior, 'notificationType')) {
const parsed = asNotificationType(behavior.notificationType);
if (parsed === undefined) {
context.resolved.ankiConnect.behavior.notificationType =
DEFAULT_CONFIG.ankiConnect.behavior.notificationType;
context.warn(
'ankiConnect.behavior.notificationType',
behavior.notificationType,
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
} else {
context.resolved.ankiConnect.behavior.notificationType = parsed;
}
}
if (isObject(ac.isLapis)) {
const lapisEnabled = asBoolean(ac.isLapis.enabled);
if (lapisEnabled !== undefined) {
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
} else if (ac.isLapis.enabled !== undefined) {
context.warn(
'ankiConnect.isLapis.enabled',
ac.isLapis.enabled,
context.resolved.ankiConnect.isLapis.enabled,
'Expected boolean.',
);
}
const sentenceCardModel = asString(ac.isLapis.sentenceCardModel);
if (sentenceCardModel !== undefined) {
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
} else if (ac.isLapis.sentenceCardModel !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardModel',
ac.isLapis.sentenceCardModel,
context.resolved.ankiConnect.isLapis.sentenceCardModel,
'Expected string.',
);
}
if (ac.isLapis.sentenceCardSentenceField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardSentenceField',
ac.isLapis.sentenceCardSentenceField,
'Sentence',
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
);
}
if (ac.isLapis.sentenceCardAudioField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardAudioField',
ac.isLapis.sentenceCardAudioField,
'SentenceAudio',
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
);
}
} else if (ac.isLapis !== undefined) {
context.warn(
'ankiConnect.isLapis',
ac.isLapis,
context.resolved.ankiConnect.isLapis,
'Expected object.',
);
}
if (isObject(ac.proxy)) {
const proxyEnabled = asBoolean(proxy.enabled);
if (proxyEnabled !== undefined) {
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
} else if (proxy.enabled !== undefined) {
context.warn(
'ankiConnect.proxy.enabled',
proxy.enabled,
context.resolved.ankiConnect.proxy.enabled,
'Expected boolean.',
);
}
const proxyHost = asString(proxy.host);
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
} else if (proxy.host !== undefined) {
context.warn(
'ankiConnect.proxy.host',
proxy.host,
context.resolved.ankiConnect.proxy.host,
'Expected non-empty string.',
);
}
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
} else if (proxy.upstreamUrl !== undefined) {
context.warn(
'ankiConnect.proxy.upstreamUrl',
proxy.upstreamUrl,
context.resolved.ankiConnect.proxy.upstreamUrl,
'Expected non-empty string.',
);
}
const proxyPort = asNumber(proxy.port);
if (
proxyPort !== undefined &&
Number.isInteger(proxyPort) &&
proxyPort >= 1 &&
proxyPort <= 65535
) {
context.resolved.ankiConnect.proxy.port = proxyPort;
} else if (proxy.port !== undefined) {
context.warn(
'ankiConnect.proxy.port',
proxy.port,
context.resolved.ankiConnect.proxy.port,
'Expected integer between 1 and 65535.',
);
}
} else if (ac.proxy !== undefined) {
context.warn(
'ankiConnect.proxy',
ac.proxy,
context.resolved.ankiConnect.proxy,
'Expected object.',
);
}
if (isObject(ac.ai)) {
const aiEnabled = asBoolean(ac.ai.enabled);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai.enabled !== undefined) {
context.warn(
'ankiConnect.ai.enabled',
ac.ai.enabled,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean.',
);
}
const aiModel = asString(ac.ai.model);
if (aiModel !== undefined) {
context.resolved.ankiConnect.ai.model = aiModel;
} else if (ac.ai.model !== undefined) {
context.warn(
'ankiConnect.ai.model',
ac.ai.model,
context.resolved.ankiConnect.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(ac.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
} else if (ac.ai.systemPrompt !== undefined) {
context.warn(
'ankiConnect.ai.systemPrompt',
ac.ai.systemPrompt,
context.resolved.ankiConnect.ai.systemPrompt,
'Expected string.',
);
}
} else {
const aiEnabled = asBoolean(ac.ai);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ac.ai !== undefined) {
context.warn(
'ankiConnect.ai',
ac.ai,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean or object.',
);
}
}
if (Array.isArray(ac.tags)) {
const normalizedTags = ac.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ac.tags.length) {
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ac.tags,
context.resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ac.tags !== undefined) {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ac.tags,
context.resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
const legacy = ac as Record<string, unknown>;
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
};
const asPositiveInteger = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
};
const asPositiveNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed <= 0) {
return undefined;
}
return parsed;
};
const asNonNegativeNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed < 0) {
return undefined;
}
return parsed;
};
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
return value === 'static' || value === 'avif' ? value : undefined;
};
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
};
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
return value === 'append' || value === 'prepend' ? value : undefined;
};
const mapLegacy = <T>(
key: string,
parse: (value: unknown) => T | undefined,
apply: (value: T) => void,
fallback: unknown,
message: string,
): void => {
const value = legacy[key];
if (value === undefined) return;
const parsed = parse(value);
if (parsed === undefined) {
context.warn(`ankiConnect.${key}`, value, fallback, message);
return;
}
apply(parsed);
};
if (!hasOwn(fields, 'audio')) {
mapLegacy(
'audioField',
asString,
(value) => {
context.resolved.ankiConnect.fields.audio = value;
},
context.resolved.ankiConnect.fields.audio,
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
asString,
(value) => {
context.resolved.ankiConnect.fields.image = value;
},
context.resolved.ankiConnect.fields.image,
'Expected string.',
);
}
if (!hasOwn(fields, 'sentence')) {
mapLegacy(
'sentenceField',
asString,
(value) => {
context.resolved.ankiConnect.fields.sentence = value;
},
context.resolved.ankiConnect.fields.sentence,
'Expected string.',
);
}
if (!hasOwn(fields, 'miscInfo')) {
mapLegacy(
'miscInfoField',
asString,
(value) => {
context.resolved.ankiConnect.fields.miscInfo = value;
},
context.resolved.ankiConnect.fields.miscInfo,
'Expected string.',
);
}
if (!hasOwn(metadata, 'pattern')) {
mapLegacy(
'miscInfoPattern',
asString,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
context.resolved.ankiConnect.metadata.pattern,
'Expected string.',
);
}
if (!hasOwn(media, 'generateAudio')) {
mapLegacy(
'generateAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateAudio = value;
},
context.resolved.ankiConnect.media.generateAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'generateImage')) {
mapLegacy(
'generateImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateImage = value;
},
context.resolved.ankiConnect.media.generateImage,
'Expected boolean.',
);
}
if (!hasOwn(media, 'imageType')) {
mapLegacy(
'imageType',
asImageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
context.resolved.ankiConnect.media.imageType,
"Expected 'static' or 'avif'.",
);
}
if (!hasOwn(media, 'imageFormat')) {
mapLegacy(
'imageFormat',
asImageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
context.resolved.ankiConnect.media.imageFormat,
"Expected 'jpg', 'png', or 'webp'.",
);
}
if (!hasOwn(media, 'imageQuality')) {
mapLegacy(
'imageQuality',
(value) => asIntegerInRange(value, 1, 100),
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
context.resolved.ankiConnect.media.imageQuality,
'Expected integer between 1 and 100.',
);
}
if (!hasOwn(media, 'imageMaxWidth')) {
mapLegacy(
'imageMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxWidth = value;
},
context.resolved.ankiConnect.media.imageMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'imageMaxHeight')) {
mapLegacy(
'imageMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxHeight = value;
},
context.resolved.ankiConnect.media.imageMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedFps')) {
mapLegacy(
'animatedFps',
(value) => asIntegerInRange(value, 1, 60),
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
context.resolved.ankiConnect.media.animatedFps,
'Expected integer between 1 and 60.',
);
}
if (!hasOwn(media, 'animatedMaxWidth')) {
mapLegacy(
'animatedMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxWidth = value;
},
context.resolved.ankiConnect.media.animatedMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedMaxHeight')) {
mapLegacy(
'animatedMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxHeight = value;
},
context.resolved.ankiConnect.media.animatedMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedCrf')) {
mapLegacy(
'animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
context.resolved.ankiConnect.media.animatedCrf,
'Expected integer between 0 and 63.',
);
}
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
mapLegacy(
'syncAnimatedImageToWordAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
},
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'audioPadding')) {
mapLegacy(
'audioPadding',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
context.resolved.ankiConnect.media.audioPadding,
'Expected non-negative number.',
);
}
if (!hasOwn(media, 'fallbackDuration')) {
mapLegacy(
'fallbackDuration',
asPositiveNumber,
(value) => {
context.resolved.ankiConnect.media.fallbackDuration = value;
},
context.resolved.ankiConnect.media.fallbackDuration,
'Expected positive number.',
);
}
if (!hasOwn(media, 'maxMediaDuration')) {
mapLegacy(
'maxMediaDuration',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.maxMediaDuration = value;
},
context.resolved.ankiConnect.media.maxMediaDuration,
'Expected non-negative number.',
);
}
if (!hasOwn(behavior, 'overwriteAudio')) {
mapLegacy(
'overwriteAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteAudio = value;
},
context.resolved.ankiConnect.behavior.overwriteAudio,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'overwriteImage')) {
mapLegacy(
'overwriteImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteImage = value;
},
context.resolved.ankiConnect.behavior.overwriteImage,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'mediaInsertMode')) {
mapLegacy(
'mediaInsertMode',
asMediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
context.resolved.ankiConnect.behavior.mediaInsertMode,
"Expected 'append' or 'prepend'.",
);
}
if (!hasOwn(behavior, 'highlightWord')) {
mapLegacy(
'highlightWord',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.highlightWord = value;
},
context.resolved.ankiConnect.behavior.highlightWord,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'notificationType')) {
mapLegacy(
'notificationType',
asNotificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
mapLegacy(
'autoUpdateNewCards',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
},
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
'Expected boolean.',
);
}
const knownWordsConfig = isObject(ac.knownWords)
? (ac.knownWords as Record<string, unknown>)
: {};
const nPlusOneConfig = isObject(ac.nPlusOne) ? (ac.nPlusOne as Record<string, unknown>) : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (knownWordsConfig.highlightEnabled !== undefined) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else {
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
}
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
if (knownWordsRefreshMinutes !== undefined) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (asNumber(behavior.nPlusOneRefreshMinutes) !== undefined) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
Number.isInteger(nPlusOneMinSentenceWords) &&
nPlusOneMinSentenceWords > 0;
if (nPlusOneMinSentenceWords !== undefined) {
if (hasValidNPlusOneMinSentenceWords) {
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
} else {
context.warn(
'ankiConnect.nPlusOne.minSentenceWords',
nPlusOneConfig.minSentenceWords,
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
} else {
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (knownWordsMatchMode !== undefined) {
context.warn(
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (legacyBehaviorNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else {
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const DEFAULT_FIELDS = [
DEFAULT_CONFIG.ankiConnect.fields.word,
'Word',
'Reading',
'Word Reading',
];
const knownWordsDecks = knownWordsConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks as Record<string, unknown>)) {
const deckName = deck.trim();
if (!deckName) continue;
if (Array.isArray(fields) && fields.every((f) => typeof f === 'string')) {
resolved[deckName] = (fields as string[]).map((f) => f.trim()).filter((f) => f.length > 0);
} else {
context.warn(
`ankiConnect.knownWords.decks["${deckName}"]`,
fields,
DEFAULT_FIELDS,
'Expected an array of field name strings.',
);
resolved[deckName] = DEFAULT_FIELDS;
}
}
context.resolved.ankiConnect.knownWords.decks = resolved;
} else if (Array.isArray(knownWordsDecks)) {
const normalized = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = DEFAULT_FIELDS;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
resolved,
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
);
}
} else if (knownWordsDecks !== undefined) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle)
? (context.src.subtitleStyle as Record<string, unknown>)
: {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
if (knownWordsColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
}
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
initializeAnkiConnectResolution(context, ankiConnect);
applyAnkiModernResolution(context, ankiConnect, behavior, media);
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
}
+57
View File
@@ -0,0 +1,57 @@
import type { ResolveContext } from '../context';
import { asBoolean, asString, isObject } from '../shared';
export function applyAiResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.ai)) {
const aiEnabled = asBoolean(ankiConnect.ai.enabled);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ankiConnect.ai.enabled !== undefined) {
context.warn(
'ankiConnect.ai.enabled',
ankiConnect.ai.enabled,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean.',
);
}
const aiModel = asString(ankiConnect.ai.model);
if (aiModel !== undefined) {
context.resolved.ankiConnect.ai.model = aiModel;
} else if (ankiConnect.ai.model !== undefined) {
context.warn(
'ankiConnect.ai.model',
ankiConnect.ai.model,
context.resolved.ankiConnect.ai.model,
'Expected string.',
);
}
const aiSystemPrompt = asString(ankiConnect.ai.systemPrompt);
if (aiSystemPrompt !== undefined) {
context.resolved.ankiConnect.ai.systemPrompt = aiSystemPrompt;
} else if (ankiConnect.ai.systemPrompt !== undefined) {
context.warn(
'ankiConnect.ai.systemPrompt',
ankiConnect.ai.systemPrompt,
context.resolved.ankiConnect.ai.systemPrompt,
'Expected string.',
);
}
} else {
const aiEnabled = asBoolean(ankiConnect.ai);
if (aiEnabled !== undefined) {
context.resolved.ankiConnect.ai.enabled = aiEnabled;
} else if (ankiConnect.ai !== undefined) {
context.warn(
'ankiConnect.ai',
ankiConnect.ai,
context.resolved.ankiConnect.ai.enabled,
'Expected boolean or object.',
);
}
}
}
@@ -0,0 +1,81 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
const LEGACY_KEYS = new Set([
'wordField',
'audioField',
'imageField',
'sentenceField',
'miscInfoField',
'miscInfoPattern',
'generateAudio',
'generateImage',
'imageType',
'imageFormat',
'imageQuality',
'imageMaxWidth',
'imageMaxHeight',
'animatedFps',
'animatedMaxWidth',
'animatedMaxHeight',
'animatedCrf',
'syncAnimatedImageToWordAudio',
'audioPadding',
'fallbackDuration',
'maxMediaDuration',
'overwriteAudio',
'overwriteImage',
'mediaInsertMode',
'highlightWord',
'notificationType',
'autoUpdateNewCards',
]);
export function initializeAnkiConnectResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
const {
knownWords: _knownWordsConfigFromAnkiConnect,
nPlusOne: _nPlusOneConfigFromAnkiConnect,
ai: _ankiAiConfig,
...ankiConnectWithoutKnownWordsOrNPlusOne
} = ankiConnect;
const ankiConnectWithoutLegacy = Object.fromEntries(
Object.entries(ankiConnectWithoutKnownWordsOrNPlusOne).filter(([key]) => !LEGACY_KEYS.has(key)),
);
context.resolved.ankiConnect = {
...context.resolved.ankiConnect,
...(isObject(ankiConnectWithoutLegacy)
? (ankiConnectWithoutLegacy as Partial<(typeof context.resolved)['ankiConnect']>)
: {}),
fields: {
...context.resolved.ankiConnect.fields,
},
media: {
...context.resolved.ankiConnect.media,
},
knownWords: {
...context.resolved.ankiConnect.knownWords,
},
behavior: {
...context.resolved.ankiConnect.behavior,
},
proxy: {
...context.resolved.ankiConnect.proxy,
},
metadata: {
...context.resolved.ankiConnect.metadata,
},
isLapis: {
...context.resolved.ankiConnect.isLapis,
},
isKiku: {
...context.resolved.ankiConnect.isKiku,
...(isObject(ankiConnect.isKiku)
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
};
}
+19
View File
@@ -0,0 +1,19 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyAnkiKikuResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isKiku.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isKiku.fieldGrouping',
context.resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
}
@@ -0,0 +1,307 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean, asColor, asNumber, asString, isObject } from '../shared';
import { hasOwn } from './shared';
export function applyAnkiKnownWordsResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
behavior: Record<string, unknown>,
): void {
const knownWordsConfig = isObject(ankiConnect.knownWords) ? ankiConnect.knownWords : {};
const nPlusOneConfig = isObject(ankiConnect.nPlusOne) ? ankiConnect.nPlusOne : {};
const knownWordsHighlightEnabled = asBoolean(knownWordsConfig.highlightEnabled);
if (knownWordsHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled = knownWordsHighlightEnabled;
} else if (hasOwn(knownWordsConfig, 'highlightEnabled')) {
context.warn(
'ankiConnect.knownWords.highlightEnabled',
knownWordsConfig.highlightEnabled,
context.resolved.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
const legacyBehaviorNPlusOneHighlightEnabled = asBoolean(behavior.nPlusOneHighlightEnabled);
if (legacyBehaviorNPlusOneHighlightEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.highlightEnabled =
legacyBehaviorNPlusOneHighlightEnabled;
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Legacy key is deprecated; use ankiConnect.knownWords.highlightEnabled',
);
} else if (hasOwn(behavior, 'nPlusOneHighlightEnabled')) {
context.warn(
'ankiConnect.behavior.nPlusOneHighlightEnabled',
behavior.nPlusOneHighlightEnabled,
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
} else {
context.resolved.ankiConnect.knownWords.highlightEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.highlightEnabled;
}
}
const knownWordsMaturityEnabled = asBoolean(knownWordsConfig.maturityEnabled);
if (knownWordsMaturityEnabled !== undefined) {
context.resolved.ankiConnect.knownWords.maturityEnabled = knownWordsMaturityEnabled;
} else if (hasOwn(knownWordsConfig, 'maturityEnabled')) {
context.warn(
'ankiConnect.knownWords.maturityEnabled',
knownWordsConfig.maturityEnabled,
context.resolved.ankiConnect.knownWords.maturityEnabled,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.maturityEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
} else {
context.resolved.ankiConnect.knownWords.maturityEnabled =
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
}
const knownWordsMatureThresholdDays = asNumber(knownWordsConfig.matureThresholdDays);
const hasValidMatureThresholdDays =
knownWordsMatureThresholdDays !== undefined &&
Number.isInteger(knownWordsMatureThresholdDays) &&
knownWordsMatureThresholdDays >= 1;
if (hasOwn(knownWordsConfig, 'matureThresholdDays')) {
if (hasValidMatureThresholdDays) {
context.resolved.ankiConnect.knownWords.matureThresholdDays = knownWordsMatureThresholdDays;
} else {
context.warn(
'ankiConnect.knownWords.matureThresholdDays',
knownWordsConfig.matureThresholdDays,
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
'Expected an integer of at least 1.',
);
context.resolved.ankiConnect.knownWords.matureThresholdDays =
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
}
} else {
context.resolved.ankiConnect.knownWords.matureThresholdDays =
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
}
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
const hasValidKnownWordsRefreshMinutes =
knownWordsRefreshMinutes !== undefined &&
Number.isInteger(knownWordsRefreshMinutes) &&
knownWordsRefreshMinutes > 0;
if (hasOwn(knownWordsConfig, 'refreshMinutes')) {
if (hasValidKnownWordsRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = knownWordsRefreshMinutes;
} else {
context.warn(
'ankiConnect.knownWords.refreshMinutes',
knownWordsConfig.refreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else if (hasOwn(behavior, 'nPlusOneRefreshMinutes')) {
const legacyBehaviorNPlusOneRefreshMinutes = asNumber(behavior.nPlusOneRefreshMinutes);
const hasValidLegacyRefreshMinutes =
legacyBehaviorNPlusOneRefreshMinutes !== undefined &&
Number.isInteger(legacyBehaviorNPlusOneRefreshMinutes) &&
legacyBehaviorNPlusOneRefreshMinutes > 0;
if (hasValidLegacyRefreshMinutes) {
context.resolved.ankiConnect.knownWords.refreshMinutes = legacyBehaviorNPlusOneRefreshMinutes;
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes,
'Legacy key is deprecated; use ankiConnect.knownWords.refreshMinutes',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneRefreshMinutes',
behavior.nPlusOneRefreshMinutes,
context.resolved.ankiConnect.knownWords.refreshMinutes,
'Expected a positive integer.',
);
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
} else {
context.resolved.ankiConnect.knownWords.refreshMinutes =
DEFAULT_CONFIG.ankiConnect.knownWords.refreshMinutes;
}
const knownWordsAddMinedWordsImmediately = asBoolean(knownWordsConfig.addMinedWordsImmediately);
if (knownWordsAddMinedWordsImmediately !== undefined) {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
knownWordsAddMinedWordsImmediately;
} else if (knownWordsConfig.addMinedWordsImmediately !== undefined) {
context.warn(
'ankiConnect.knownWords.addMinedWordsImmediately',
knownWordsConfig.addMinedWordsImmediately,
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately,
'Expected boolean.',
);
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
} else {
context.resolved.ankiConnect.knownWords.addMinedWordsImmediately =
DEFAULT_CONFIG.ankiConnect.knownWords.addMinedWordsImmediately;
}
const nPlusOneEnabled = asBoolean(nPlusOneConfig.enabled);
if (nPlusOneEnabled !== undefined) {
context.resolved.ankiConnect.nPlusOne.enabled = nPlusOneEnabled;
} else if (nPlusOneConfig.enabled !== undefined) {
context.warn(
'ankiConnect.nPlusOne.enabled',
nPlusOneConfig.enabled,
context.resolved.ankiConnect.nPlusOne.enabled,
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
const nPlusOneMinSentenceWords = asNumber(nPlusOneConfig.minSentenceWords);
const hasValidNPlusOneMinSentenceWords =
nPlusOneMinSentenceWords !== undefined &&
Number.isInteger(nPlusOneMinSentenceWords) &&
nPlusOneMinSentenceWords > 0;
if (hasOwn(nPlusOneConfig, 'minSentenceWords')) {
if (hasValidNPlusOneMinSentenceWords) {
context.resolved.ankiConnect.nPlusOne.minSentenceWords = nPlusOneMinSentenceWords;
} else {
context.warn(
'ankiConnect.nPlusOne.minSentenceWords',
nPlusOneConfig.minSentenceWords,
context.resolved.ankiConnect.nPlusOne.minSentenceWords,
'Expected a positive integer.',
);
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
} else {
context.resolved.ankiConnect.nPlusOne.minSentenceWords =
DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords;
}
const knownWordsMatchMode = asString(knownWordsConfig.matchMode);
const legacyBehaviorNPlusOneMatchMode = asString(behavior.nPlusOneMatchMode);
const hasValidKnownWordsMatchMode =
knownWordsMatchMode === 'headword' || knownWordsMatchMode === 'surface';
const hasValidLegacyMatchMode =
legacyBehaviorNPlusOneMatchMode === 'headword' || legacyBehaviorNPlusOneMatchMode === 'surface';
if (hasValidKnownWordsMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = knownWordsMatchMode;
} else if (hasOwn(knownWordsConfig, 'matchMode')) {
context.warn(
'ankiConnect.knownWords.matchMode',
knownWordsConfig.matchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
} else if (hasOwn(behavior, 'nPlusOneMatchMode')) {
if (hasValidLegacyMatchMode) {
context.resolved.ankiConnect.knownWords.matchMode = legacyBehaviorNPlusOneMatchMode;
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode,
'Legacy key is deprecated; use ankiConnect.knownWords.matchMode',
);
} else {
context.warn(
'ankiConnect.behavior.nPlusOneMatchMode',
behavior.nPlusOneMatchMode,
context.resolved.ankiConnect.knownWords.matchMode,
"Expected 'headword' or 'surface'.",
);
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
} else {
context.resolved.ankiConnect.knownWords.matchMode =
DEFAULT_CONFIG.ankiConnect.knownWords.matchMode;
}
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
const knownWordsDecks = knownWordsConfig.decks;
if (isObject(knownWordsDecks)) {
const resolved: Record<string, string[]> = {};
for (const [deck, fields] of Object.entries(knownWordsDecks)) {
const deckName = deck.trim();
if (!deckName) continue;
if (Array.isArray(fields) && fields.every((field) => typeof field === 'string')) {
resolved[deckName] = fields
.map((field) => field.trim())
.filter((field) => field.length > 0);
} else {
context.warn(
`ankiConnect.knownWords.decks["${deckName}"]`,
fields,
defaultFields,
'Expected an array of field name strings.',
);
resolved[deckName] = defaultFields;
}
}
context.resolved.ankiConnect.knownWords.decks = resolved;
} else if (Array.isArray(knownWordsDecks)) {
const normalized = knownWordsDecks
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const resolved: Record<string, string[]> = {};
for (const deck of new Set(normalized)) {
resolved[deck] = defaultFields;
}
context.resolved.ankiConnect.knownWords.decks = resolved;
if (normalized.length > 0) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
resolved,
'Legacy array format is deprecated; use object format: { "Deck Name": ["Field1", "Field2"] }',
);
}
} else if (knownWordsDecks !== undefined) {
context.warn(
'ankiConnect.knownWords.decks',
knownWordsDecks,
context.resolved.ankiConnect.knownWords.decks,
'Expected an object mapping deck names to field arrays.',
);
}
const rawSubtitleStyle = isObject(context.src.subtitleStyle) ? context.src.subtitleStyle : {};
const hasCanonicalKnownWordColor = rawSubtitleStyle.knownWordColor !== undefined;
const knownWordsColor = asColor(knownWordsConfig.color);
if (knownWordsColor !== undefined) {
if (!hasCanonicalKnownWordColor) {
context.resolved.subtitleStyle.knownWordColor = knownWordsColor;
}
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Legacy key is deprecated; use subtitleStyle.knownWordColor',
);
} else if (knownWordsConfig.color !== undefined) {
context.warn(
'ankiConnect.knownWords.color',
knownWordsConfig.color,
context.resolved.subtitleStyle.knownWordColor,
'Expected a hex color value.',
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { ResolveContext } from '../context';
import { asBoolean, asString, isObject } from '../shared';
export function applyLapisResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.isLapis)) {
const lapisEnabled = asBoolean(ankiConnect.isLapis.enabled);
if (lapisEnabled !== undefined) {
context.resolved.ankiConnect.isLapis.enabled = lapisEnabled;
} else if (ankiConnect.isLapis.enabled !== undefined) {
context.warn(
'ankiConnect.isLapis.enabled',
ankiConnect.isLapis.enabled,
context.resolved.ankiConnect.isLapis.enabled,
'Expected boolean.',
);
}
const sentenceCardModel = asString(ankiConnect.isLapis.sentenceCardModel);
if (sentenceCardModel !== undefined) {
context.resolved.ankiConnect.isLapis.sentenceCardModel = sentenceCardModel;
} else if (ankiConnect.isLapis.sentenceCardModel !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardModel',
ankiConnect.isLapis.sentenceCardModel,
context.resolved.ankiConnect.isLapis.sentenceCardModel,
'Expected string.',
);
}
if (ankiConnect.isLapis.sentenceCardSentenceField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardSentenceField',
ankiConnect.isLapis.sentenceCardSentenceField,
'Sentence',
'Deprecated key; sentence-card sentence field is fixed to Sentence.',
);
}
if (ankiConnect.isLapis.sentenceCardAudioField !== undefined) {
context.warn(
'ankiConnect.isLapis.sentenceCardAudioField',
ankiConnect.isLapis.sentenceCardAudioField,
'SentenceAudio',
'Deprecated key; sentence-card audio field is fixed to SentenceAudio.',
);
}
} else if (ankiConnect.isLapis !== undefined) {
context.warn(
'ankiConnect.isLapis',
ankiConnect.isLapis,
context.resolved.ankiConnect.isLapis,
'Expected object.',
);
}
}
+364
View File
@@ -0,0 +1,364 @@
import type { ResolveContext } from '../context';
import { asBoolean, asNumber, asString } from '../shared';
import { asNotificationType, hasOwn } from './shared';
export function applyAnkiLegacyResolution(
context: ResolveContext,
legacy: Record<string, unknown>,
behavior: Record<string, unknown>,
fields: Record<string, unknown>,
media: Record<string, unknown>,
metadata: Record<string, unknown>,
): void {
const asIntegerInRange = (value: unknown, min: number, max: number): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
};
const asPositiveInteger = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || !Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
};
const asPositiveNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed <= 0) {
return undefined;
}
return parsed;
};
const asNonNegativeNumber = (value: unknown): number | undefined => {
const parsed = asNumber(value);
if (parsed === undefined || parsed < 0) {
return undefined;
}
return parsed;
};
const asImageType = (value: unknown): 'static' | 'avif' | undefined => {
return value === 'static' || value === 'avif' ? value : undefined;
};
const asImageFormat = (value: unknown): 'jpg' | 'png' | 'webp' | undefined => {
return value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined;
};
const asMediaInsertMode = (value: unknown): 'append' | 'prepend' | undefined => {
return value === 'append' || value === 'prepend' ? value : undefined;
};
const mapLegacy = <T>(
key: string,
parse: (value: unknown) => T | undefined,
apply: (value: T) => void,
fallback: unknown,
message: string,
): void => {
const value = legacy[key];
if (value === undefined) return;
const parsed = parse(value);
if (parsed === undefined) {
context.warn(`ankiConnect.${key}`, value, fallback, message);
return;
}
apply(parsed);
};
if (!hasOwn(fields, 'audio')) {
mapLegacy(
'audioField',
asString,
(value) => {
context.resolved.ankiConnect.fields.audio = value;
},
context.resolved.ankiConnect.fields.audio,
'Expected string.',
);
}
if (!hasOwn(fields, 'word')) {
mapLegacy(
'wordField',
asString,
(value) => {
context.resolved.ankiConnect.fields.word = value;
},
context.resolved.ankiConnect.fields.word,
'Expected string.',
);
}
if (!hasOwn(fields, 'image')) {
mapLegacy(
'imageField',
asString,
(value) => {
context.resolved.ankiConnect.fields.image = value;
},
context.resolved.ankiConnect.fields.image,
'Expected string.',
);
}
if (!hasOwn(fields, 'sentence')) {
mapLegacy(
'sentenceField',
asString,
(value) => {
context.resolved.ankiConnect.fields.sentence = value;
},
context.resolved.ankiConnect.fields.sentence,
'Expected string.',
);
}
if (!hasOwn(fields, 'miscInfo')) {
mapLegacy(
'miscInfoField',
asString,
(value) => {
context.resolved.ankiConnect.fields.miscInfo = value;
},
context.resolved.ankiConnect.fields.miscInfo,
'Expected string.',
);
}
if (!hasOwn(metadata, 'pattern')) {
mapLegacy(
'miscInfoPattern',
asString,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
context.resolved.ankiConnect.metadata.pattern,
'Expected string.',
);
}
if (!hasOwn(media, 'generateAudio')) {
mapLegacy(
'generateAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateAudio = value;
},
context.resolved.ankiConnect.media.generateAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'generateImage')) {
mapLegacy(
'generateImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.generateImage = value;
},
context.resolved.ankiConnect.media.generateImage,
'Expected boolean.',
);
}
if (!hasOwn(media, 'imageType')) {
mapLegacy(
'imageType',
asImageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
context.resolved.ankiConnect.media.imageType,
"Expected 'static' or 'avif'.",
);
}
if (!hasOwn(media, 'imageFormat')) {
mapLegacy(
'imageFormat',
asImageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
context.resolved.ankiConnect.media.imageFormat,
"Expected 'jpg', 'png', or 'webp'.",
);
}
if (!hasOwn(media, 'imageQuality')) {
mapLegacy(
'imageQuality',
(value) => asIntegerInRange(value, 1, 100),
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
context.resolved.ankiConnect.media.imageQuality,
'Expected integer between 1 and 100.',
);
}
if (!hasOwn(media, 'imageMaxWidth')) {
mapLegacy(
'imageMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxWidth = value;
},
context.resolved.ankiConnect.media.imageMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'imageMaxHeight')) {
mapLegacy(
'imageMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.imageMaxHeight = value;
},
context.resolved.ankiConnect.media.imageMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedFps')) {
mapLegacy(
'animatedFps',
(value) => asIntegerInRange(value, 1, 60),
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
context.resolved.ankiConnect.media.animatedFps,
'Expected integer between 1 and 60.',
);
}
if (!hasOwn(media, 'animatedMaxWidth')) {
mapLegacy(
'animatedMaxWidth',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxWidth = value;
},
context.resolved.ankiConnect.media.animatedMaxWidth,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedMaxHeight')) {
mapLegacy(
'animatedMaxHeight',
asPositiveInteger,
(value) => {
context.resolved.ankiConnect.media.animatedMaxHeight = value;
},
context.resolved.ankiConnect.media.animatedMaxHeight,
'Expected positive integer.',
);
}
if (!hasOwn(media, 'animatedCrf')) {
mapLegacy(
'animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
context.resolved.ankiConnect.media.animatedCrf,
'Expected integer between 0 and 63.',
);
}
if (!hasOwn(media, 'syncAnimatedImageToWordAudio')) {
mapLegacy(
'syncAnimatedImageToWordAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio = value;
},
context.resolved.ankiConnect.media.syncAnimatedImageToWordAudio,
'Expected boolean.',
);
}
if (!hasOwn(media, 'audioPadding')) {
mapLegacy(
'audioPadding',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
context.resolved.ankiConnect.media.audioPadding,
'Expected non-negative number.',
);
}
if (!hasOwn(media, 'fallbackDuration')) {
mapLegacy(
'fallbackDuration',
asPositiveNumber,
(value) => {
context.resolved.ankiConnect.media.fallbackDuration = value;
},
context.resolved.ankiConnect.media.fallbackDuration,
'Expected positive number.',
);
}
if (!hasOwn(media, 'maxMediaDuration')) {
mapLegacy(
'maxMediaDuration',
asNonNegativeNumber,
(value) => {
context.resolved.ankiConnect.media.maxMediaDuration = value;
},
context.resolved.ankiConnect.media.maxMediaDuration,
'Expected non-negative number.',
);
}
if (!hasOwn(behavior, 'overwriteAudio')) {
mapLegacy(
'overwriteAudio',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteAudio = value;
},
context.resolved.ankiConnect.behavior.overwriteAudio,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'overwriteImage')) {
mapLegacy(
'overwriteImage',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.overwriteImage = value;
},
context.resolved.ankiConnect.behavior.overwriteImage,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'mediaInsertMode')) {
mapLegacy(
'mediaInsertMode',
asMediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
context.resolved.ankiConnect.behavior.mediaInsertMode,
"Expected 'append' or 'prepend'.",
);
}
if (!hasOwn(behavior, 'highlightWord')) {
mapLegacy(
'highlightWord',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.highlightWord = value;
},
context.resolved.ankiConnect.behavior.highlightWord,
'Expected boolean.',
);
}
if (!hasOwn(behavior, 'notificationType')) {
mapLegacy(
'notificationType',
asNotificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
context.resolved.ankiConnect.behavior.notificationType,
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
if (!hasOwn(behavior, 'autoUpdateNewCards')) {
mapLegacy(
'autoUpdateNewCards',
asBoolean,
(value) => {
context.resolved.ankiConnect.behavior.autoUpdateNewCards = value;
},
context.resolved.ankiConnect.behavior.autoUpdateNewCards,
'Expected boolean.',
);
}
}
@@ -0,0 +1,55 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean } from '../shared';
import { applyModernValue } from './modern-value';
import { asNotificationType } from './shared';
export function applyModernBehaviorResolution(
context: ResolveContext,
behavior: Record<string, unknown>,
): void {
for (const key of [
'overwriteAudio',
'overwriteImage',
'highlightWord',
'autoUpdateNewCards',
] as const) {
applyModernValue(
context,
behavior,
key,
`ankiConnect.behavior.${key}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect.behavior[key],
(value) => {
context.resolved.ankiConnect.behavior[key] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
behavior,
'mediaInsertMode',
'ankiConnect.behavior.mediaInsertMode',
(value) => (value === 'append' || value === 'prepend' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.behavior.mediaInsertMode,
(value) => {
context.resolved.ankiConnect.behavior.mediaInsertMode = value;
},
"Expected 'append' or 'prepend'.",
);
applyModernValue(
context,
behavior,
'notificationType',
'ankiConnect.behavior.notificationType',
asNotificationType,
DEFAULT_CONFIG.ankiConnect.behavior.notificationType,
(value) => {
context.resolved.ankiConnect.behavior.notificationType = value;
},
"Expected 'overlay', 'system', 'both', 'none', 'osd', or 'osd-system'.",
);
}
@@ -0,0 +1,24 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asString } from '../shared';
import { applyModernValue } from './modern-value';
export function applyModernFieldsResolution(
context: ResolveContext,
fields: Record<string, unknown>,
): void {
for (const key of ['word', 'audio', 'image', 'sentence', 'miscInfo', 'translation'] as const) {
applyModernValue(
context,
fields,
key,
`ankiConnect.fields.${key}`,
asString,
DEFAULT_CONFIG.ankiConnect.fields[key],
(value) => {
context.resolved.ankiConnect.fields[key] = value;
},
'Expected string.',
);
}
}
@@ -0,0 +1,145 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asBoolean } from '../shared';
import {
applyModernValue,
asIntegerInRange,
asNonNegativeInteger,
asNonNegativeNumber,
asPositiveNumber,
} from './modern-value';
export function applyModernMediaResolution(
context: ResolveContext,
media: Record<string, unknown>,
): void {
for (const key of [
'generateAudio',
'generateImage',
'syncAnimatedImageToWordAudio',
'normalizeAudio',
'mirrorMpvVolume',
] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asBoolean,
DEFAULT_CONFIG.ankiConnect.media[key],
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected boolean.',
);
}
applyModernValue(
context,
media,
'imageType',
'ankiConnect.media.imageType',
(value) => (value === 'static' || value === 'avif' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.media.imageType,
(value) => {
context.resolved.ankiConnect.media.imageType = value;
},
"Expected 'static' or 'avif'.",
);
applyModernValue(
context,
media,
'imageFormat',
'ankiConnect.media.imageFormat',
(value) => (value === 'jpg' || value === 'png' || value === 'webp' ? value : undefined),
DEFAULT_CONFIG.ankiConnect.media.imageFormat,
(value) => {
context.resolved.ankiConnect.media.imageFormat = value;
},
"Expected 'jpg', 'png', or 'webp'.",
);
applyModernValue(
context,
media,
'imageQuality',
'ankiConnect.media.imageQuality',
(value) => asIntegerInRange(value, 1, 100),
DEFAULT_CONFIG.ankiConnect.media.imageQuality,
(value) => {
context.resolved.ankiConnect.media.imageQuality = value;
},
'Expected integer between 1 and 100.',
);
for (const key of [
'imageMaxWidth',
'imageMaxHeight',
'animatedMaxWidth',
'animatedMaxHeight',
] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asNonNegativeInteger,
DEFAULT_CONFIG.ankiConnect.media[key] ?? 0,
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected non-negative integer.',
);
}
applyModernValue(
context,
media,
'animatedFps',
'ankiConnect.media.animatedFps',
(value) => asIntegerInRange(value, 1, 60),
DEFAULT_CONFIG.ankiConnect.media.animatedFps,
(value) => {
context.resolved.ankiConnect.media.animatedFps = value;
},
'Expected integer between 1 and 60.',
);
applyModernValue(
context,
media,
'animatedCrf',
'ankiConnect.media.animatedCrf',
(value) => asIntegerInRange(value, 0, 63),
DEFAULT_CONFIG.ankiConnect.media.animatedCrf,
(value) => {
context.resolved.ankiConnect.media.animatedCrf = value;
},
'Expected integer between 0 and 63.',
);
applyModernValue(
context,
media,
'audioPadding',
'ankiConnect.media.audioPadding',
asNonNegativeNumber,
DEFAULT_CONFIG.ankiConnect.media.audioPadding,
(value) => {
context.resolved.ankiConnect.media.audioPadding = value;
},
'Expected non-negative number.',
);
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
applyModernValue(
context,
media,
key,
`ankiConnect.media.${key}`,
asPositiveNumber,
DEFAULT_CONFIG.ankiConnect.media[key],
(value) => {
context.resolved.ankiConnect.media[key] = value;
},
'Expected positive number.',
);
}
}
@@ -0,0 +1,22 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
import { asString } from '../shared';
import { applyModernValue } from './modern-value';
export function applyModernMetadataResolution(
context: ResolveContext,
metadata: Record<string, unknown>,
): void {
applyModernValue(
context,
metadata,
'pattern',
'ankiConnect.metadata.pattern',
asString,
DEFAULT_CONFIG.ankiConnect.metadata.pattern,
(value) => {
context.resolved.ankiConnect.metadata.pattern = value;
},
'Expected string.',
);
}
@@ -0,0 +1,46 @@
import type { ResolveContext } from '../context';
import { asNumber } from '../shared';
import { hasOwn } from './shared';
export function asIntegerInRange(value: unknown, min: number, max: number): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: undefined;
}
export function asNonNegativeInteger(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
export function asPositiveNumber(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && parsed > 0 ? parsed : undefined;
}
export function asNonNegativeNumber(value: unknown): number | undefined {
const parsed = asNumber(value);
return parsed !== undefined && parsed >= 0 ? parsed : undefined;
}
export function applyModernValue<T>(
context: ResolveContext,
source: Record<string, unknown>,
key: string,
path: string,
parse: (value: unknown) => T | undefined,
fallback: T,
apply: (value: T) => void,
message: string,
): void {
if (!hasOwn(source, key)) return;
const raw = source[key];
const parsed = parse(raw);
if (parsed === undefined) {
apply(fallback);
context.warn(path, raw, fallback, message);
return;
}
apply(parsed);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ResolveContext } from '../context';
import { isObject } from '../shared';
import { applyAiResolution } from './ai';
import { applyLapisResolution } from './lapis';
import { applyModernBehaviorResolution } from './modern-behavior';
import { applyModernFieldsResolution } from './modern-fields';
import { applyModernMediaResolution } from './modern-media';
import { applyModernMetadataResolution } from './modern-metadata';
import { applyProxyResolution } from './proxy';
import { applyTagsResolution } from './tags';
export function applyAnkiModernResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
behavior: Record<string, unknown>,
media: Record<string, unknown>,
): void {
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
applyModernFieldsResolution(context, fields);
applyModernMediaResolution(context, media);
applyModernBehaviorResolution(context, behavior);
applyModernMetadataResolution(context, metadata);
applyLapisResolution(context, ankiConnect);
applyProxyResolution(context, ankiConnect);
applyAiResolution(context, ankiConnect);
applyTagsResolution(context, ankiConnect);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ResolveContext } from '../context';
import { asBoolean, asNumber, asString, isObject } from '../shared';
export function applyProxyResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (isObject(ankiConnect.proxy)) {
const proxy = ankiConnect.proxy;
const proxyEnabled = asBoolean(proxy.enabled);
if (proxyEnabled !== undefined) {
context.resolved.ankiConnect.proxy.enabled = proxyEnabled;
} else if (proxy.enabled !== undefined) {
context.warn(
'ankiConnect.proxy.enabled',
proxy.enabled,
context.resolved.ankiConnect.proxy.enabled,
'Expected boolean.',
);
}
const proxyHost = asString(proxy.host);
if (proxyHost !== undefined && proxyHost.trim().length > 0) {
context.resolved.ankiConnect.proxy.host = proxyHost.trim();
} else if (proxy.host !== undefined) {
context.warn(
'ankiConnect.proxy.host',
proxy.host,
context.resolved.ankiConnect.proxy.host,
'Expected non-empty string.',
);
}
const proxyUpstreamUrl = asString(proxy.upstreamUrl);
if (proxyUpstreamUrl !== undefined && proxyUpstreamUrl.trim().length > 0) {
context.resolved.ankiConnect.proxy.upstreamUrl = proxyUpstreamUrl.trim();
} else if (proxy.upstreamUrl !== undefined) {
context.warn(
'ankiConnect.proxy.upstreamUrl',
proxy.upstreamUrl,
context.resolved.ankiConnect.proxy.upstreamUrl,
'Expected non-empty string.',
);
}
const proxyPort = asNumber(proxy.port);
if (
proxyPort !== undefined &&
Number.isInteger(proxyPort) &&
proxyPort >= 1 &&
proxyPort <= 65535
) {
context.resolved.ankiConnect.proxy.port = proxyPort;
} else if (proxy.port !== undefined) {
context.warn(
'ankiConnect.proxy.port',
proxy.port,
context.resolved.ankiConnect.proxy.port,
'Expected integer between 1 and 65535.',
);
}
} else if (ankiConnect.proxy !== undefined) {
context.warn(
'ankiConnect.proxy',
ankiConnect.proxy,
context.resolved.ankiConnect.proxy,
'Expected object.',
);
}
}
@@ -0,0 +1,9 @@
import { isNotificationType, type NotificationType } from '../../../types/notification';
export function asNotificationType(value: unknown): NotificationType | undefined {
return isNotificationType(value) ? value : undefined;
}
export function hasOwn(obj: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyTagsResolution(
context: ResolveContext,
ankiConnect: Record<string, unknown>,
): void {
if (Array.isArray(ankiConnect.tags)) {
const normalizedTags = ankiConnect.tags
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (normalizedTags.length === ankiConnect.tags.length) {
context.resolved.ankiConnect.tags = [...new Set(normalizedTags)];
} else {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ankiConnect.tags,
context.resolved.ankiConnect.tags,
'Expected an array of non-empty strings.',
);
}
} else if (ankiConnect.tags !== undefined) {
context.resolved.ankiConnect.tags = DEFAULT_CONFIG.ankiConnect.tags;
context.warn(
'ankiConnect.tags',
ankiConnect.tags,
context.resolved.ankiConnect.tags,
'Expected an array of strings.',
);
}
}
+26
View File
@@ -125,6 +125,32 @@ test('settings registry groups annotation display fields by config group', () =>
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
});
test('settings registry groups maturity settings under Known Words with color controls', () => {
assert.equal(field('ankiConnect.knownWords.maturityEnabled').subsection, 'Known Words');
assert.equal(field('ankiConnect.knownWords.matureThresholdDays').subsection, 'Known Words');
for (const tier of ['new', 'learning', 'young', 'mature']) {
const tierField = field(`subtitleStyle.knownWordMaturityColors.${tier}`);
assert.equal(tierField.subsection, 'Known Words');
assert.equal(tierField.control, 'color');
}
});
test('settings registry orders maturity colors from least mature to mature', () => {
const knownWordsPaths = fields
.filter((candidate) => candidate.subsection === 'Known Words')
.map((candidate) => candidate.configPath);
assert.deepEqual(knownWordsPaths, [
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.maturityEnabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
]);
});
test('settings registry routes known words sync, n+1, and frequency config to behavior', () => {
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').category, 'behavior');
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
+11
View File
@@ -163,6 +163,12 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
'subtitleStyle.knownWordMaturityColors.learning',
'subtitleStyle.knownWordMaturityColors.young',
'subtitleStyle.knownWordMaturityColors.mature',
'subtitleStyle.fontColor',
'subtitleStyle.backgroundColor',
'subtitleStyle.hoverTokenColor',
@@ -353,6 +359,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
path.startsWith('ankiConnect.nPlusOne.') ||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path.startsWith('subtitleStyle.knownWordMaturityColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.knownWordColor' ||
path === 'subtitleStyle.nPlusOneColor' ||
@@ -516,6 +523,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
return 'key-code';
}
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
@@ -533,6 +541,9 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
function subsectionForPath(path: string): string | undefined {
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.maturityEnabled') return 'Known Words';
if (path === 'ankiConnect.knownWords.matureThresholdDays') return 'Known Words';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'Known Words';
if (path === 'ankiConnect.nPlusOne.enabled') return 'N+1';
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createStatsMiningContext } from '../stats-server/mining-support.js';
test('mining timing observer errors do not replace successful phase results', async () => {
const { timeMiningPhase } = createStatsMiningContext({
onMiningTiming: () => {
throw new Error('observer failed');
},
});
const result = await timeMiningPhase('word', 'test', async () => 42);
assert.equal(result, 42);
});
test('mining timing observer errors do not replace phase errors', async () => {
const phaseError = new Error('phase failed');
const { timeMiningPhase } = createStatsMiningContext({
onMiningTiming: () => {
throw new Error('observer failed');
},
});
await assert.rejects(
timeMiningPhase('word', 'test', async () => {
throw phaseError;
}),
(error: unknown) => error === phaseError,
);
});
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import {
registerStatsAnalyticsRoutes,
registerStatsIntegrationRoutes,
registerStatsLibraryRoutes,
registerStatsMiningRoutes,
registerStatsStaticRoutes,
} from '../stats-server/routes.js';
describe('stats server route groups', () => {
test('exposes focused registrars for createStatsApp composition', () => {
for (const registrar of [
registerStatsAnalyticsRoutes,
registerStatsLibraryRoutes,
registerStatsIntegrationRoutes,
registerStatsMiningRoutes,
registerStatsStaticRoutes,
]) {
assert.equal(typeof registrar, 'function');
}
});
});
+228 -12
View File
@@ -497,6 +497,8 @@ describe('stats server API routes', () => {
cachePath,
JSON.stringify({
version: 1,
refreshedAtMs: 1,
scope: 'deck:test',
words: ['する'],
}),
);
@@ -561,6 +563,69 @@ describe('stats server API routes', () => {
});
});
it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({
version: 4,
refreshedAtMs: 1,
scope: 'deck:test',
notes: {
'101': [{ word: 'する', reading: 'する' }],
'102': [{ word: '猫', reading: null }],
},
tiers: { '101': 'mature', '102': 'young' },
}),
);
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 reports no known words when the cache format is unrecognized', async () => {
await withTempDir(async (dir) => {
const cachePath = path.join(dir, 'known-words.json');
fs.writeFileSync(
cachePath,
JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }),
);
const app = createStatsApp(
createMockTracker({
getSessionWordsByLine: async () => [
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
],
}),
{ knownWordCachePath: cachePath },
);
const res = await app.request('/api/stats/sessions?limit=5');
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body[0].knownWordsSeen, 0);
});
});
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
let seenSessionId = 0;
let seenLimit = 0;
@@ -609,6 +674,8 @@ describe('stats server API routes', () => {
cachePath,
JSON.stringify({
version: 1,
refreshedAtMs: 1,
scope: 'deck:test',
words: ['知る', '猫'],
}),
);
@@ -900,9 +967,11 @@ describe('stats server API routes', () => {
}),
);
const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary');
const res = await app.request(
'/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
);
assert.equal(res.status, 200);
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]);
assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
});
it('GET /api/stats/vocabulary returns POS fields', async () => {
@@ -1001,6 +1070,36 @@ describe('stats server API routes', () => {
assert.equal(res.status, 404);
});
it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => {
const assignments: Array<{ animeId: number; body: unknown }> = [];
const app = createStatsApp(
createMockTracker({
reassignAnimeAnilist: async (animeId: number, body: unknown) => {
assignments.push({ animeId, body });
},
}),
);
for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ anilistId }),
});
assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
}
assert.deepEqual(assignments, []);
const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
const res = await app.request('/api/stats/anime/1/anilist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
assert.equal(res.status, 200);
assert.deepEqual(assignments, [{ animeId: 1, body }]);
});
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/1/cover');
@@ -1348,6 +1447,86 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
let yomitanCalls = 0;
let mediaCalls = 0;
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => {
yomitanCalls += 1;
return 777;
},
createMediaGenerator: () => ({
generateAudio: async () => {
mediaCalls += 1;
return Buffer.from('audio');
},
generateScreenshot: async () => {
mediaCalls += 1;
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: ' ',
}),
});
assert.equal(res.status, 400);
assert.equal(yomitanCalls, 0);
assert.equal(mediaCalls, 0);
});
});
it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
let mediaCalls = 0;
const app = createStatsApp(createMockTracker(), {
createMediaGenerator: () => ({
generateAudio: async () => {
mediaCalls += 1;
return Buffer.from('audio');
},
generateScreenshot: async () => {
mediaCalls += 1;
return Buffer.from('image');
},
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
}),
});
assert.equal(res.status, 500);
assert.equal(mediaCalls, 0);
});
});
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -2155,7 +2334,7 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
});
});
@@ -2279,8 +2458,8 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
assert.equal(fields.ExpressionAudio, undefined);
assert.equal(fields.SelectionText, undefined);
});
@@ -2335,8 +2514,8 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.avif">$/);
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
assert.equal(fields.ExpressionAudio, undefined);
assert.equal(fields.SelectionText, undefined);
});
@@ -2492,7 +2671,7 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
assert.match(
updateRequest?.params?.note?.fields?.Voice ?? '',
/^\[sound:subminer_audio_\d+\.mp3\]$/,
/^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
);
},
{ notesInfoFields: null },
@@ -2580,7 +2759,11 @@ Aligned English subtitle
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
assert.match(
updateRequest?.params?.note?.fields?.Picture ?? '',
/^<img src="subminer_image_\d+_12345\.jpg">$/,
);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
});
});
@@ -2670,6 +2853,26 @@ Aligned English subtitle
assert.deepEqual(await res.json(), { ok: true });
});
it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
let deleteCalls = 0;
const app = createStatsApp(
createMockTracker({
deleteSessions: async () => {
deleteCalls += 1;
},
}),
);
const res = await app.request('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
});
assert.equal(res.status, 400);
assert.equal(deleteCalls, 0);
});
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
@@ -2694,8 +2897,10 @@ Aligned English subtitle
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
let recordCalls = 0;
globalThis.fetch = (async () =>
new Response(
let requestSignal: AbortSignal | null | undefined;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
requestSignal = init?.signal;
return new Response(
JSON.stringify({
data: {
Page: {
@@ -2707,7 +2912,8 @@ Aligned English subtitle
status: 200,
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
},
)) as typeof fetch;
);
}) as typeof fetch;
try {
const app = createStatsApp(createMockTracker(), {
@@ -2725,6 +2931,7 @@ Aligned English subtitle
assert.equal(res.status, 200);
assert.equal(acquireCalls, 1);
assert.equal(recordCalls, 1);
assert.ok(requestSignal instanceof AbortSignal);
} finally {
globalThis.fetch = originalFetch;
}
@@ -2923,6 +3130,15 @@ Aligned English subtitle
});
});
it('returns not found for malformed percent-encoded asset paths', async () => {
await withTempDir(async (dir) => {
const app = createStatsApp(createMockTracker(), { staticDir: dir });
const res = await app.request('/assets/%');
assert.equal(res.status, 404);
});
});
it('fetches and serves missing cover art on demand', async () => {
let ensureCalls = 0;
let hasCover = false;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
enrichSessionsWithKnownWordMetrics,
loadKnownWordsSet,
parseEventTypesQuery,
parseIntQuery,
parseTrendFillEmpty,
parseTrendGroupBy,
parseTrendRange,
} from './route-support.js';
export function registerStatsAnalyticsRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: { knownWordCachePath?: string },
): void {
app.get('/api/stats/overview', async (c) => {
const [rawSessions, rollups, hints] = await Promise.all([
tracker.getSessionSummaries(5),
tracker.getDailyRollups(14),
tracker.getQueryHints(),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('overview', { sessions, rollups, hints }));
});
app.get('/api/stats/daily-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
const rollups = await tracker.getDailyRollups(limit);
return c.json(statsJson('dailyRollups', rollups));
});
app.get('/api/stats/monthly-rollups', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
const rollups = await tracker.getMonthlyRollups(limit);
return c.json(statsJson('monthlyRollups', rollups));
});
app.get('/api/stats/streak-calendar', async (c) => {
const days = parseIntQuery(c.req.query('days'), 90, 365);
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
});
app.get('/api/stats/trends/episodes-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
});
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
});
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
});
app.get('/api/stats/trends/dashboard', async (c) => {
const range = parseTrendRange(c.req.query('range'));
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
return c.json(
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
);
});
app.get('/api/stats/sessions', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const rawSessions = await tracker.getSessionSummaries(limit);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('sessions', sessions));
});
app.get('/api/stats/sessions/:id/timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
const rawLimit = c.req.query('limit');
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
const timeline = await tracker.getSessionTimeline(id, limit);
return c.json(statsJson('sessionTimeline', timeline));
});
app.get('/api/stats/sessions/:id/events', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
const eventTypes = parseEventTypesQuery(c.req.query('types'));
const events = await tracker.getSessionEvents(id, limit, eventTypes);
return c.json(statsJson('sessionEvents', events));
});
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
const id = parseIntQuery(c.req.param('id'), 0);
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
// Get per-line word occurrences for the session.
const wordsByLine = await tracker.getSessionWordsByLine(id);
// Build cumulative filtered occurrence counts per recorded line index.
// The stats UI uses line-count progress to align this series with the session
// timeline, so preserve the stored line position rather than compressing gaps.
const totalLineGroups = new Map<number, number>();
const knownLineGroups = new Map<number, number>();
for (const row of wordsByLine) {
totalLineGroups.set(
row.lineIndex,
(totalLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
);
if (knownWordsSet.has(row.headword)) {
knownLineGroups.set(
row.lineIndex,
(knownLineGroups.get(row.lineIndex) ?? 0) + row.occurrenceCount,
);
}
}
const maxLineIndex = Math.max(...totalLineGroups.keys(), ...knownLineGroups.keys(), -1);
let knownWordsSeen = 0;
let totalWordsSeen = 0;
const knownByLinesSeen: Array<{
linesSeen: number;
knownWordsSeen: number;
totalWordsSeen: number;
}> = [];
for (let lineIdx = 0; lineIdx <= maxLineIndex; lineIdx += 1) {
knownWordsSeen += knownLineGroups.get(lineIdx) ?? 0;
totalWordsSeen += totalLineGroups.get(lineIdx) ?? 0;
knownByLinesSeen.push({
linesSeen: lineIdx,
knownWordsSeen,
totalWordsSeen,
});
}
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
});
}
@@ -0,0 +1,224 @@
import type { Hono } from 'hono';
import type { AnkiConnectConfig } from '../../../types.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../../types/stats-http-contract.js';
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildAnkiNotePreview,
countKnownWords,
enrichSessionsWithKnownWordMetrics,
loadKnownWordsSet,
parseIntQuery,
} from './route-support.js';
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
const ANILIST_FETCH_TIMEOUT_MS = 3_000;
export function registerStatsIntegrationRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: {
knownWordCachePath?: string;
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
app.get('/api/stats/anilist/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('anilistSearch', []));
try {
await options?.anilistRateLimiter?.acquire();
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANILIST_FETCH_TIMEOUT_MS),
body: JSON.stringify({
query: `query ($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME) {
id
episodes
season
seasonYear
description(asHtml: false)
coverImage { large medium }
title { romaji english native }
}
}
}`,
variables: { search: query },
}),
});
options?.anilistRateLimiter?.recordResponse(res.headers);
if (res.status === 429) {
return c.json(statsJson('anilistSearch', []));
}
const json = (await res.json()) as {
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
};
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
} catch {
return c.json(statsJson('anilistSearch', []));
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
return c.json(statsJson('knownWords', [...knownWordsSet]));
});
app.get('/api/stats/known-words-summary', async (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
}
const headwords = await tracker.getAllDistinctHeadwords();
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
400,
);
}
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) {
return c.json(
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
);
}
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
});
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
if (
typeof body?.anilistId !== 'number' ||
!Number.isInteger(body.anilistId) ||
body.anilistId <= 0
) {
return c.body(null, 400);
}
await tracker.reassignAnimeAnilist(animeId, body);
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
});
registerStatsCoverRoutes(app, tracker);
app.get('/api/stats/episode/:videoId/detail', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const rawSessions = await tracker.getEpisodeSessions(videoId);
const words = await tracker.getEpisodeWords(videoId);
const cardEvents = await tracker.getEpisodeCardEvents(videoId);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
});
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try {
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({
action: 'guiBrowse',
version: 6,
params: { query: `nid:${noteId}` },
}),
});
const result = (await response.json()) as StatsAnkiBrowseResponse;
return c.json(statsJson('ankiBrowse', result));
} catch {
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
}
});
app.post('/api/stats/anki/notesInfo', async (c) => {
const body = await c.req.json().catch(() => null);
const noteIds: number[] = Array.isArray(body?.noteIds)
? body.noteIds.filter(
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
)
: [];
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
const resolvedNoteIds = Array.from(
new Set(
noteIds.map((noteId) => {
const resolvedNoteId = options?.resolveAnkiNoteId?.(noteId);
return Number.isInteger(resolvedNoteId) && (resolvedNoteId as number) > 0
? (resolvedNoteId as number)
: noteId;
}),
),
);
try {
const ankiConfig = getAnkiConnectConfig();
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
body: JSON.stringify({
action: 'notesInfo',
version: 6,
params: { notes: resolvedNoteIds },
}),
});
const result = (await response.json()) as {
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
};
return c.json(
statsJson(
'ankiNotesInfo',
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
),
);
} catch {
return c.json(statsJson('ankiNotesInfo', []), 502);
}
});
}
@@ -0,0 +1,193 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
buildSentenceSearchOptions,
enrichSessionsWithKnownWordMetrics,
parseBooleanQuery,
parseExcludedWordsBody,
parseIntQuery,
} from './route-support.js';
export function registerStatsLibraryRoutes(
app: Hono,
tracker: ImmersionTrackerService,
options?: {
knownWordCachePath?: string;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
},
): void {
app.get('/api/stats/vocabulary', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const excludePos = c.req
.query('excludePos')
?.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
const vocab = await tracker.getVocabularyStats(limit, excludePos);
return c.json(statsJson('vocabulary', vocab));
});
app.get('/api/stats/excluded-words', async (c) => {
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
});
app.put('/api/stats/excluded-words', async (c) => {
const body = await c.req.json().catch(() => null);
const words = parseExcludedWordsBody(body);
if (!words) return c.body(null, 400);
await tracker.replaceStatsExcludedWords(words);
return c.json(statsJson('setExcludedWords', { ok: true }));
});
app.get('/api/stats/vocabulary/occurrences', async (c) => {
const headword = (c.req.query('headword') ?? '').trim();
const word = (c.req.query('word') ?? '').trim();
const reading = (c.req.query('reading') ?? '').trim();
if (!headword || !word) {
return c.json(statsJson('wordOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
return c.json(statsJson('wordOccurrences', occurrences));
});
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('sentenceSearch', []));
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
const searchOptions = await buildSentenceSearchOptions(
query,
searchByHeadword,
options?.resolveSentenceSearchHeadwords,
);
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
return c.json(statsJson('sentenceSearch', rows));
});
app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit);
return c.json(statsJson('kanji', kanji));
});
app.get('/api/stats/kanji/occurrences', async (c) => {
const kanji = (c.req.query('kanji') ?? '').trim();
if (!kanji) {
return c.json(statsJson('kanjiOccurrences', []), 400);
}
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
return c.json(statsJson('kanjiOccurrences', occurrences));
});
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
const wordId = parseIntQuery(c.req.param('wordId'), 0);
if (wordId <= 0) return c.body(null, 400);
const detail = await tracker.getWordDetail(wordId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
const similarWords = await tracker.getSimilarWords(wordId);
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
});
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0);
if (kanjiId <= 0) return c.body(null, 400);
const detail = await tracker.getKanjiDetail(kanjiId);
if (!detail) return c.body(null, 404);
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
const words = await tracker.getKanjiWords(kanjiId);
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
});
app.get('/api/stats/media', async (c) => {
const library = await tracker.getMediaLibrary();
return c.json(statsJson('mediaLibrary', library));
});
app.get('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.json(statsJson('error', null), 400);
const [detail, rawSessions, rollups] = await Promise.all([
tracker.getMediaDetail(videoId),
tracker.getMediaSessions(videoId, 100),
tracker.getMediaDailyRollups(videoId, 90),
]);
const sessions = await enrichSessionsWithKnownWordMetrics(
tracker,
rawSessions,
options?.knownWordCachePath,
);
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
});
app.get('/api/stats/anime', async (c) => {
const rows = await tracker.getAnimeLibrary();
return c.json(statsJson('animeLibrary', rows));
});
app.get('/api/stats/anime/:animeId', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const detail = await tracker.getAnimeDetail(animeId);
if (!detail) return c.body(null, 404);
const [episodes, anilistEntries] = await Promise.all([
tracker.getAnimeEpisodes(animeId),
tracker.getAnimeAnilistEntries(animeId),
]);
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
});
app.get('/api/stats/anime/:animeId/words', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
if (animeId <= 0) return c.body(null, 400);
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
});
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
if (animeId <= 0) return c.body(null, 400);
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
});
app.patch('/api/stats/media/:videoId/watched', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
await tracker.setVideoWatched(videoId, watched);
return c.json(statsJson('setVideoWatched', { ok: true }));
});
app.delete('/api/stats/sessions', async (c) => {
const body = await c.req.json().catch(() => null);
const ids = Array.isArray(body?.sessionIds)
? body.sessionIds.filter(
(id: unknown): id is number => Number.isSafeInteger(id) && (id as number) > 0,
)
: [];
if (ids.length === 0) return c.body(null, 400);
await tracker.deleteSessions(ids);
return c.json(statsJson('deleteSessions', { ok: true }));
});
app.delete('/api/stats/sessions/:sessionId', async (c) => {
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
if (sessionId <= 0) return c.body(null, 400);
await tracker.deleteSession(sessionId);
return c.json(statsJson('deleteSession', { ok: true }));
});
app.delete('/api/stats/media/:videoId', async (c) => {
const videoId = parseIntQuery(c.req.param('videoId'), 0);
if (videoId <= 0) return c.body(null, 400);
await tracker.deleteVideo(videoId);
return c.json(statsJson('deleteVideo', { ok: true }));
});
}
@@ -0,0 +1,469 @@
import type { Hono } from 'hono';
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { AnkiConnectClient } from '../../../anki-connect.js';
import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js';
import {
applyStatsWordAndSentenceCardFields,
createStatsMiningContext,
getStatsDirectMiningAudioFieldNames,
getStatsWordMiningAudioFieldName,
resolveStatsNoteFieldName,
shouldUseStatsLapisKikuCardFields,
statsMiningLogger,
type StatsMiningRouteOptions,
type StatsServerNoteInfo,
} from './mining-support.js';
export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteOptions): void {
const {
getAnkiConnectConfig,
getEffectiveMiningDeckName,
getSecondarySubtitleLanguages,
getStatsMiningAlassPath,
timeMiningPhase,
} = createStatsMiningContext(options);
app.post('/api/stats/mine-card', async (c) => {
const body = await c.req.json().catch(() => null);
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
const word = typeof body?.word === 'string' ? body.word.trim() : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
const rawMode = c.req.query('mode');
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
if (
!sourcePath ||
!sentence ||
(mode === 'word' && !word) ||
!Number.isFinite(startMs) ||
!Number.isFinite(endMs)
) {
return c.json(
statsJson('mineCard', {
error: 'sourcePath, sentence, startMs, and endMs are required',
}),
400,
);
}
if (endMs <= startMs) {
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
}
if (!existsSync(sourcePath)) {
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
}
const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) {
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
}
const addYomitanNote = options?.addYomitanNote;
if (mode === 'word' && !addYomitanNote) {
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence' && !bodySecondaryText) {
try {
retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ??
resolveRetimedSecondarySubtitleTextFromSidecar
)({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
alassPath: getStatsMiningAlassPath(),
});
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve retimed secondary subtitle for stats mining:',
error instanceof Error ? error.message : String(error),
);
}
}
const secondaryText =
bodySecondaryText ||
retimedSecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
const startSec = startMs / 1000;
const endSec = endMs / 1000;
const rawDuration = endSec - startSec;
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const highlightedSentence = word
? sentence.replace(
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
`<b>${word}</b>`,
)
: sentence;
const generateAudio = ankiConfig.media?.generateAudio !== false;
const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio';
const imageType = ankiConfig.media?.imageType ?? 'static';
const syncAnimatedImageToWordAudio =
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
const audioPromise = generateAudio
? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(
sourcePath,
startSec,
clampedEndSec,
audioPadding,
null,
normalizeAudio,
),
)
: Promise.resolve(null);
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
if (!generateImage) {
return Promise.resolve(null);
}
if (imageType === 'avif') {
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds,
}),
);
}
const midpointSec = (startSec + clampedEndSec) / 2;
return timeMiningPhase(mode, 'generateScreenshot', () =>
mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight,
}),
);
};
const imagePromise =
mode === 'word' && syncAnimatedImageToWordAudio
? Promise.resolve<Buffer | null>(null)
: createImagePromise();
const errors: string[] = [];
let noteId: number;
let effectiveDeckNamePromise: Promise<string> | null = null;
const getEffectiveDeckNameForRequest = (): Promise<string> => {
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
return effectiveDeckNamePromise;
};
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
const deckName = await getEffectiveDeckNameForRequest();
if (!deckName) {
return;
}
try {
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
client.findCards(`nid:${id}`),
);
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
} catch (err) {
errors.push(`deck: ${(err as Error).message}`);
}
};
if (mode === 'word') {
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
timeMiningPhase(
'word',
'addYomitanNote',
() => addYomitanNote!(word),
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
),
audioPromise,
imagePromise,
]);
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
return c.json(
statsJson('mineCard', {
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
}),
502,
);
}
noteId = yomitanResult.value;
await moveNoteToConfiguredDeck(noteId);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
let noteInfo: StatsServerNoteInfo | null = null;
if (
audioBuffer ||
(syncAnimatedImageToWordAudio && generateImage) ||
shouldUseStatsLapisKikuCardFields(ankiConfig)
) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
const animatedLeadInSeconds = noteInfo
? await resolveAnimatedImageLeadInSeconds({
config: ankiConfig,
noteInfo,
resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) =>
resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames),
retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename),
})
: 0;
imageBuffer = await createImagePromise(animatedLeadInSeconds);
} catch (err) {
errors.push(`image: ${(err as Error).message}`);
}
}
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
errors.push('audio: no audio generated');
}
if (generateImage && !imageBuffer) {
errors.push('image: no image generated');
}
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence;
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
try {
await timeMiningPhase('word', 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
}
}
if (imageBuffer) {
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
try {
await timeMiningPhase('word', 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
}
}
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
if (miscInfoFieldName) {
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
const filenameWithExt = videoTitle || basename(sourcePath);
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const totalMs = Math.floor(startMs);
const totalSec2 = Math.floor(totalMs / 1000);
const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0');
const secs = String(totalSec2 % 60).padStart(2, '0');
const ms = String(totalMs % 1000).padStart(3, '0');
mediaFields[miscInfoFieldName] = pattern
.replace(/%f/g, filenameWithoutExt)
.replace(/%F/g, filenameWithExt)
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
.replace(/<br>/g, '\n');
}
if (Object.keys(mediaFields).length > 0) {
try {
await timeMiningPhase('word', 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
}
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
}
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
const fields: Record<string, string> = {
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
};
if (mode === 'sentence' && secondaryText) {
fields[translationFieldName] = secondaryText;
}
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
if (mode === 'sentence') {
fields[wordFieldName] = sentence;
} else if (word) {
fields[wordFieldName] = word;
}
if (mode === 'sentence') {
fields['IsSentenceCard'] = 'x';
} else if (mode === 'audio') {
fields['IsAudioCard'] = 'x';
}
}
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
const tags = ankiConfig.tags ?? ['SubMiner'];
const addNotePromise = timeMiningPhase(
mode,
'addNote',
async () =>
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
(id) => ({
noteId: id,
}),
);
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
audioPromise,
imagePromise,
addNotePromise,
]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
if (addNoteResult.status === 'rejected') {
return c.json(
statsJson('mineCard', {
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
}),
502,
);
}
noteId = addNoteResult.value;
await moveNoteToConfiguredDeck(noteId);
const mediaFields: Record<string, string> = {};
const timestamp = Date.now();
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch {
noteInfo = null;
}
}
if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
try {
await timeMiningPhase(mode, 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
const audioValue = `[sound:${audioFilename}]`;
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
mediaFields[fieldName] = audioValue;
}
} catch (err) {
errors.push(`audio upload: ${(err as Error).message}`);
}
}
if (imageBuffer) {
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
try {
await timeMiningPhase(mode, 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) {
errors.push(`image upload: ${(err as Error).message}`);
}
}
if (miscInfoFieldName) {
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
const filenameWithExt = videoTitle || basename(sourcePath);
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const totalMs = Math.floor(startMs);
const totalSec = Math.floor(totalMs / 1000);
const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0');
const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
const secs = String(totalSec % 60).padStart(2, '0');
const ms = String(totalMs % 1000).padStart(3, '0');
const miscInfo = pattern
.replace(/%f/g, filenameWithoutExt)
.replace(/%F/g, filenameWithExt)
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
.replace(/<br>/g, '\n');
mediaFields[miscInfoFieldName] = miscInfo;
}
if (Object.keys(mediaFields).length > 0) {
try {
await timeMiningPhase(mode, 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) {
errors.push(`update fields: ${(err as Error).message}`);
}
}
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
});
}
@@ -0,0 +1,195 @@
import type { MediaGenerator } from '../../../media-generator.js';
import type { AnkiConnectConfig } from '../../../types.js';
import { createLogger } from '../../../logger.js';
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
export type StatsServerNoteInfo = {
noteId: number;
fields: Record<string, { value: string }>;
};
export type StatsServerMediaGenerator = {
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
generateScreenshot: (
...args: Parameters<MediaGenerator['generateScreenshot']>
) => Promise<Buffer | null>;
generateAnimatedImage: (
...args: Parameters<MediaGenerator['generateAnimatedImage']>
) => Promise<Buffer | null>;
};
export type StatsMiningTimingEvent = {
mode: 'word' | 'sentence' | 'audio';
phase: string;
elapsedMs: number;
noteId?: number;
};
export type StatsMiningRouteOptions = {
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
addYomitanNote?: (word: string) => Promise<number | null>;
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
};
type StatsMiningLogger = {
debug: (message: string, ...meta: unknown[]) => void;
warn: (message: string, ...meta: unknown[]) => void;
};
export const statsMiningLogger: StatsMiningLogger = createLogger('stats:mining');
export function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
): string | null {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
}
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const fieldName of fieldNames) {
const normalized = fieldName?.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
export function getStatsWordMiningAudioFieldName(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string {
return (
(noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
: null) ??
ankiConfig.fields?.audio ??
'ExpressionAudio'
);
}
export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig): boolean {
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
}
export function applyStatsWordAndSentenceCardFields(
fields: Record<string, string>,
noteInfo: StatsServerNoteInfo | null,
ankiConfig: AnkiConnectConfig,
): void {
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
if (!wordAndSentenceFlag) return;
fields[wordAndSentenceFlag] = 'x';
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
}
}
export function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
mode: 'sentence' | 'audio',
): string[] {
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
return [configuredAudioField];
}
const sentenceAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
: 'SentenceAudio';
const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: configuredAudioField;
return mode === 'sentence'
? uniqueFieldNames(sentenceAudioField)
: uniqueFieldNames(sentenceAudioField, expressionAudioField);
}
export function createStatsMiningContext(options?: StatsMiningRouteOptions) {
const nowMs = options?.nowMs ?? (() => Date.now());
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const getSecondarySubtitleLanguages = (): string[] =>
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
const getStatsMiningAlassPath = (): string | null | undefined =>
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
try {
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve Yomitan Anki deck for stats mining:',
error instanceof Error ? error.message : String(error),
);
return '';
}
};
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
try {
options?.onMiningTiming?.(event);
} catch {
// Timing observers must not affect mining execution.
}
statsMiningLogger.debug(
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
event,
);
};
const timeMiningPhase = async <T>(
mode: StatsMiningTimingEvent['mode'],
phase: string,
fn: () => Promise<T>,
details?: (value: T) => Partial<StatsMiningTimingEvent>,
): Promise<T> => {
const startedAtMs = nowMs();
try {
const value = await fn();
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
...details?.(value),
});
return value;
} catch (error) {
recordMiningTiming({ mode, phase, elapsedMs: nowMs() - startedAtMs });
throw error;
}
};
return {
getAnkiConnectConfig,
getEffectiveMiningDeckName,
getSecondarySubtitleLanguages,
getStatsMiningAlassPath,
timeMiningPhase,
};
}
@@ -0,0 +1,255 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, resolve, sep } from 'node:path';
import {
getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName,
getConfiguredWordFieldName,
getPreferredNoteFieldValue,
} from '../../../anki-field-config.js';
import {
knownWordsFromState,
parseKnownWordCacheState,
} from '../../../anki-integration/known-word-cache-format.js';
import { createLogger } from '../../../logger.js';
import type { AnkiConnectConfig } from '../../../types.js';
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
const statsKnownWordsLogger = createLogger('stats:known-words');
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
export 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);
}
export function parseTrendRange(raw: string | undefined): '7d' | '30d' | '90d' | '365d' | 'all' {
return raw === '7d' || raw === '30d' || raw === '90d' || raw === '365d' || raw === 'all'
? raw
: '30d';
}
export function parseTrendGroupBy(raw: string | undefined): 'day' | 'month' {
return raw === 'month' ? 'month' : 'day';
}
export function parseTrendFillEmpty(raw: string | undefined): boolean {
return raw !== 'false';
}
export function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
if (!raw) return undefined;
const parsed = raw
.split(',')
.map((entry) => Number.parseInt(entry.trim(), 10))
.filter((entry) => Number.isInteger(entry) && entry > 0);
return parsed.length > 0 ? parsed : undefined;
}
export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
return null;
}
const words: StatsExcludedWord[] = [];
for (const row of (body as { words: unknown[] }).words) {
if (!row || typeof row !== 'object') return null;
const { headword, word, reading } = row as Record<string, unknown>;
if (typeof headword !== 'string' || typeof word !== 'string' || typeof reading !== 'string') {
return null;
}
words.push({ headword, word, reading });
}
return words;
}
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
if (!cachePath || !existsSync(cachePath)) return null;
try {
const state = parseKnownWordCacheState(JSON.parse(readFileSync(cachePath, 'utf-8')) as unknown);
if (!state) {
// A cache that exists but does not parse is a format mismatch, not an
// empty collection; say so instead of reporting zero known words.
statsKnownWordsLogger.warn(`Unrecognized known-word cache format at ${cachePath}`);
return null;
}
return knownWordsFromState(state);
} catch {
// Treat an unreadable cache as unavailable.
}
return null;
}
export function countKnownWords(
headwords: string[],
knownWordsSet: Set<string>,
): { totalUniqueWords: number; knownWordCount: number } {
let knownWordCount = 0;
for (const headword of headwords) {
if (knownWordsSet.has(headword)) knownWordCount += 1;
}
return { totalUniqueWords: headwords.length, knownWordCount };
}
function toKnownWordRate(knownWordsSeen: number, tokensSeen: number): number {
if (!Number.isFinite(knownWordsSeen) || !Number.isFinite(tokensSeen) || tokensSeen <= 0) return 0;
return Number(((knownWordsSeen / tokensSeen) * 100).toFixed(1));
}
function summarizeFilteredWordOccurrences(
wordsByLine: Array<{ lineIndex: number; headword: string; occurrenceCount: number }>,
knownWordsSet: Set<string>,
): { knownWordsSeen: number; totalWordsSeen: number } {
let knownWordsSeen = 0;
let totalWordsSeen = 0;
for (const row of wordsByLine) {
totalWordsSeen += row.occurrenceCount;
if (knownWordsSet.has(row.headword)) knownWordsSeen += row.occurrenceCount;
}
return { knownWordsSeen, totalWordsSeen };
}
export async function enrichSessionsWithKnownWordMetrics<
Session extends { sessionId: number; tokensSeen: number },
>(
tracker: ImmersionTrackerService,
sessions: Session[],
knownWordsCachePath?: string,
): Promise<Array<Session & { knownWordsSeen: number; knownWordRate: number }>> {
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
if (!knownWordsSet) {
return sessions.map((session) => ({ ...session, knownWordsSeen: 0, knownWordRate: 0 }));
}
return Promise.all(
sessions.map(async (session) => {
let knownWordsSeen = 0;
let totalWordsSeen = 0;
try {
const summary = summarizeFilteredWordOccurrences(
await tracker.getSessionWordsByLine(session.sessionId),
knownWordsSet,
);
knownWordsSeen = summary.knownWordsSeen;
totalWordsSeen = summary.totalWordsSeen;
} catch {
knownWordsSeen = 0;
totalWordsSeen = 0;
}
return {
...session,
knownWordsSeen,
knownWordRate: toKnownWordRate(knownWordsSeen, totalWordsSeen),
};
}),
);
}
export function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined) return fallback;
const normalized = raw.trim().toLowerCase();
if (!normalized) return fallback;
return !['0', 'false', 'no', 'off'].includes(normalized);
}
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
export async function buildSentenceSearchOptions(
query: string,
searchByHeadword: boolean,
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
if (!searchByHeadword) return undefined;
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
for (const term of splitSentenceSearchTerms(query)) {
const resolved = resolveSentenceSearchHeadwords
? await resolveSentenceSearchHeadwords(term)
: [term];
const headwords = uniqueNonEmptyStrings(resolved);
if (headwords.length > 0) headwordTerms.push({ term, headwords });
}
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
}
export function buildAnkiNotePreview(
fields: Record<string, { value: string }>,
ankiConfig?: Pick<AnkiConnectConfig, 'fields'>,
): { word: string; sentence: string; translation: string } {
return {
word: getPreferredNoteFieldValue(fields, [getConfiguredWordFieldName(ankiConfig)]),
sentence: getPreferredNoteFieldValue(fields, [getConfiguredSentenceFieldName(ankiConfig)]),
translation: getPreferredNoteFieldValue(fields, [
getConfiguredTranslationFieldName(ankiConfig),
]),
};
}
function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null {
const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html';
const absoluteStaticDir = resolve(staticDir);
let decodedPath: string;
try {
decodedPath = decodeURIComponent(normalizedPath);
} catch {
return null;
}
const absolutePath = resolve(absoluteStaticDir, decodedPath);
if (
absolutePath !== absoluteStaticDir &&
!absolutePath.startsWith(`${absoluteStaticDir}${sep}`)
) {
return null;
}
if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null;
return absolutePath;
}
export function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null {
const absolutePath = resolveStatsStaticPath(staticDir, requestPath);
if (!absolutePath) return null;
const contentType =
STATS_STATIC_CONTENT_TYPES[extname(absolutePath).toLowerCase()] ?? 'application/octet-stream';
return new Response(readFileSync(absolutePath), {
headers: {
'Content-Type': contentType,
'Cache-Control': absolutePath.endsWith('index.html')
? 'no-cache'
: 'public, max-age=31536000, immutable',
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export { registerStatsAnalyticsRoutes } from './analytics-routes.js';
export { registerStatsIntegrationRoutes } from './integration-routes.js';
export { registerStatsLibraryRoutes } from './library-routes.js';
export { registerStatsMiningRoutes } from './mining-routes.js';
export { registerStatsStaticRoutes } from './static-routes.js';
@@ -0,0 +1,26 @@
import type { Hono } from 'hono';
import { createStatsStaticResponse } from './route-support.js';
export function registerStatsStaticRoutes(app: Hono, staticDir?: string): void {
if (!staticDir) return;
app.get('/assets/*', (c) => {
const response = createStatsStaticResponse(staticDir, c.req.path);
if (!response) return c.text('Not found', 404);
return response;
});
app.get('/index.html', (c) => {
const response = createStatsStaticResponse(staticDir, '/index.html');
if (!response) return c.text('Stats UI not built', 404);
return response;
});
app.get('*', (c) => {
const staticResponse = createStatsStaticResponse(staticDir, c.req.path);
if (staticResponse) return staticResponse;
const fallback = createStatsStaticResponse(staticDir, '/index.html');
if (!fallback) return c.text('Stats UI not built', 404);
return fallback;
});
}
+15
View File
@@ -10,6 +10,7 @@ import {
Token,
FrequencyDictionaryLookup,
JlptLevel,
KnownWordMaturityTier,
PartOfSpeech,
} from '../../types';
import {
@@ -43,6 +44,12 @@ export type KnownWordLookupFn = (
options?: { allowReadingOnlyMatch?: boolean },
) => boolean;
export type KnownWordTierLookupFn = (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => KnownWordMaturityTier | null;
export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
@@ -53,6 +60,7 @@ export interface TokenizerServiceDeps {
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: KnownWordLookupFn;
getKnownWordTier?: KnownWordTierLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -88,6 +96,7 @@ export interface TokenizerDepsRuntimeOptions {
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: KnownWordLookupFn;
getKnownWordTier?: KnownWordTierLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -204,6 +213,11 @@ async function applyAnnotationStage(
tokens,
{
isKnownWord: getKnownWordLookup(deps, options),
// Maturity tiers only refine known-word rendering, so they follow the
// known-word toggle rather than the N+1 gate.
...(options.knownWordsEnabled && deps.getKnownWordTier
? { getKnownWordTier: deps.getKnownWordTier }
: {}),
knownWordMatchMode: deps.getKnownWordMatchMode(),
getJlptLevel: deps.getJlptLevel,
},
@@ -242,6 +256,7 @@ export function createTokenizerDepsRuntime(
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
isKnownWord: options.isKnownWord,
getKnownWordTier: options.getKnownWordTier,
getKnownWordMatchMode: options.getKnownWordMatchMode,
getKnownWordsEnabled: options.getKnownWordsEnabled,
getJlptLevel: options.getJlptLevel,
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { KnownWordMaturityTier, MergedToken, PartOfSpeech } from '../../../types';
import { annotateTokens, AnnotationStageDeps } from './annotation-stage';
function makeToken(overrides: Partial<MergedToken> = {}): MergedToken {
return {
surface: '猫',
reading: 'ネコ',
headword: '猫',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
...overrides,
};
}
function makeDeps(overrides: Partial<AnnotationStageDeps> = {}): AnnotationStageDeps {
return {
isKnownWord: () => false,
knownWordMatchMode: 'headword',
getJlptLevel: () => null,
...overrides,
};
}
test('annotateTokens attaches maturity tier to known tokens', () => {
const tokens = [makeToken({ surface: '食べた', headword: '食べる', reading: 'タベタ' })];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '食べる',
getKnownWordTier: (text) => (text === '食べる' ? 'mature' : null),
}),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'mature');
});
test('annotateTokens leaves maturity undefined without a tier lookup dep', () => {
const tokens = [makeToken()];
const result = annotateTokens(tokens, makeDeps({ isKnownWord: () => true }));
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens leaves maturity undefined when the tier lookup has no data', () => {
const tokens = [makeToken()];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => null }),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens never attaches maturity to unknown tokens', () => {
const tokens = [makeToken()];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => false, getKnownWordTier: () => 'mature' }),
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.knownMaturity, undefined);
});
test('annotateTokens resolves maturity through the kana reading fallback', () => {
// Token 大体 with a card mined in kana (だいたい): known status comes from
// the reading fallback, so the tier must follow the same path.
const tierByText = new Map<string, KnownWordMaturityTier>([['だいたい', 'young']]);
const seenTierLookups: Array<{
text: string;
reading: string | undefined;
allowReadingOnlyMatch: boolean | undefined;
}> = [];
const tokens = [makeToken({ surface: '大体', headword: '大体', reading: 'だいたい', endPos: 2 })];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === 'だいたい',
getKnownWordTier: (text, reading, options) => {
seenTierLookups.push({
text,
reading,
allowReadingOnlyMatch: options?.allowReadingOnlyMatch,
});
return tierByText.get(text) ?? null;
},
}),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'young');
// The fallback lookup must opt out of reading-only matching, exactly like
// the boolean known-status fallback.
const fallbackLookup = seenTierLookups.find((lookup) => lookup.text === 'だいたい');
assert.equal(fallbackLookup?.allowReadingOnlyMatch, false);
});
test('annotateTokens keeps maturity on POS-excluded known tokens', () => {
// Known-word annotations survive the POS noise filter; the tier must too.
const tokens = [makeToken({ surface: 'の', headword: 'の', reading: '', pos1: '助詞' })];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'learning' }),
);
assert.equal(result[0]?.isKnown, true);
assert.equal(result[0]?.knownMaturity, 'learning');
});
test('annotateTokens strips maturity when known-word annotation is disabled', () => {
const tokens = [makeToken({ knownMaturity: 'mature' })];
const result = annotateTokens(
tokens,
makeDeps({ isKnownWord: () => true, getKnownWordTier: () => 'mature' }),
{ knownWordsEnabled: false },
);
assert.equal(result[0]?.isKnown, false);
assert.equal(result[0]?.knownMaturity, undefined);
});
@@ -7,7 +7,13 @@ import {
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
resolveAnnotationPos2ExclusionSet,
} from '../../../token-pos2-exclusions';
import { JlptLevel, MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
import {
JlptLevel,
KnownWordMaturityTier,
MergedToken,
NPlusOneMatchMode,
PartOfSpeech,
} from '../../../types';
import { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
import {
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
@@ -36,6 +42,11 @@ export interface AnnotationStageDeps {
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => boolean;
getKnownWordTier?: (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => KnownWordMaturityTier | null;
knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
}
@@ -52,7 +63,7 @@ export interface AnnotationStageOptions {
sourceText?: string;
}
function resolveKnownWordText(
export function resolveKnownWordText(
surface: string,
headword: string,
matchMode: NPlusOneMatchMode,
@@ -560,7 +571,7 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
// (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 {
export function resolveCompleteTokenReading(token: MergedToken): string | undefined {
const normalizedReading = token.reading.trim();
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
return undefined;
@@ -573,7 +584,7 @@ function resolveCompleteTokenReading(token: MergedToken): string | undefined {
// 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(
export function resolveKnownWordReadingForMatch(
token: MergedToken,
knownWordMatchMode: NPlusOneMatchMode,
): string | undefined {
@@ -616,6 +627,30 @@ function computeTokenKnownStatus(
);
}
// Maturity tier for a token already confirmed known, following the same
// primary + kana-fallback lookup sequence as computeTokenKnownStatus so the
// tier always describes a note the boolean match could have come from.
function computeTokenKnownMaturity(
token: MergedToken,
getKnownWordTier: NonNullable<AnnotationStageDeps['getKnownWordTier']>,
knownWordMatchMode: NPlusOneMatchMode,
): KnownWordMaturityTier | undefined {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
const matchReading = resolveKnownWordReadingForMatch(token, knownWordMatchMode);
const primaryTier = matchText ? getKnownWordTier(matchText, matchReading) : null;
if (primaryTier) {
return primaryTier;
}
const fallbackReading = resolveCompleteTokenReading(token);
if (!fallbackReading || fallbackReading === matchText.trim()) {
return undefined;
}
return (
getKnownWordTier(fallbackReading, undefined, { allowReadingOnlyMatch: false }) ?? undefined
);
}
function filterTokenFrequencyRank(
token: MergedToken,
pos1Exclusions: ReadonlySet<string>,
@@ -677,6 +712,11 @@ export function annotateTokens(
: false;
nPlusOneKnownStatuses[index] = isKnownForMatching;
const knownMaturity =
knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier
? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode)
: undefined;
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
// A confirmed character-name match must survive the POS noise filter:
@@ -696,6 +736,7 @@ export function annotateTokens(
return {
...strippedToken,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
};
}
@@ -712,6 +753,7 @@ export function annotateTokens(
return {
...token,
isKnown: knownWordsEnabled ? isKnownForMatching : false,
knownMaturity,
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
frequencyRank,
jlptLevel,
+58
View File
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import {
configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs,
normalizeStartupArgv,
normalizeLaunchMpvTargets,
@@ -21,10 +22,17 @@ import {
shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyBackgroundBootstrapCommandLineSwitches,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime';
test('background bootstrap exits through Electron so Chromium children shut down', () => {
const exitCodes: number[] = [];
exitBackgroundBootstrap({ exit: (code) => exitCodes.push(code) });
assert.deepEqual(exitCodes, [0]);
});
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
const originalPlatform = process.platform;
try {
@@ -151,6 +159,56 @@ test('applyEarlyLinuxCommandLineSwitches appends password store before main star
]);
});
test('background bootstrap keeps the GPU in-process so no child outlives its AppImage mount', () => {
const collect = () => {
const switches: Array<[string, string | undefined]> = [];
return {
switches,
commandLine: {
appendSwitch: (name: string, value?: string) => {
switches.push([name, value]);
},
},
};
};
const bootstrap = collect();
applyBackgroundBootstrapCommandLineSwitches(
bootstrap.commandLine,
['SubMiner.AppImage', '--start', '--background'],
{},
'linux',
);
assert.deepEqual(bootstrap.switches, [['in-process-gpu', undefined]]);
const detachedChild = collect();
applyBackgroundBootstrapCommandLineSwitches(
detachedChild.commandLine,
['SubMiner.AppImage', '--start', '--background'],
{ SUBMINER_BACKGROUND_CHILD: '1' },
'linux',
);
assert.deepEqual(detachedChild.switches, []);
const foreground = collect();
applyBackgroundBootstrapCommandLineSwitches(
foreground.commandLine,
['SubMiner.AppImage', '--stop'],
{},
'linux',
);
assert.deepEqual(foreground.switches, []);
const windows = collect();
applyBackgroundBootstrapCommandLineSwitches(
windows.commandLine,
['SubMiner.exe', '--start', '--background'],
{},
'win32',
);
assert.deepEqual(windows.switches, []);
});
test('transported AppImage visibility commands forward through app control', () => {
assert.equal(
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
+24
View File
@@ -35,6 +35,10 @@ type EarlyAppLike = {
setPath: (name: 'userData', value: string) => void;
};
type BackgroundBootstrapAppLike = {
exit: (code: number) => void;
};
type CommandLineLike = {
appendSwitch: (name: string, value?: string) => void;
};
@@ -132,6 +136,22 @@ export function applyEarlyLinuxCommandLineSwitches(
);
}
// The detach bootstrap exits within milliseconds, but a separate GPU child it
// spawned survives app.exit() and keeps executing from the bootstrap's FUSE
// mount until session teardown finally unmaps it — SIGBUS, surfaced by DrKonqi
// as a "Service Crash" notification on every video close. Keeping the GPU
// in-process means the bootstrap leaves no child behind.
export function applyBackgroundBootstrapCommandLineSwitches(
commandLine: CommandLineLike,
argv: string[],
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== 'linux') return;
if (!shouldDetachBackgroundLaunch(argv, env)) return;
commandLine.appendSwitch('in-process-gpu');
}
function consumesLaunchMpvValue(token: string): boolean {
return (
token.startsWith('--') &&
@@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process
return true;
}
export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
app.exit(0);
}
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
const args = parseCliArgs(argv);
+5 -1
View File
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help';
import {
applyBackgroundBootstrapCommandLineSwitches,
configureEarlyAppPaths,
exitBackgroundBootstrap,
normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets,
normalizeStartupArgv,
@@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() {
process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env));
applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env);
const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
@@ -308,7 +311,8 @@ async function runEntryProcess(): Promise<void> {
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
exitBackgroundBootstrap(app);
return;
}
+2
View File
@@ -4560,6 +4560,8 @@ const {
},
isKnownWord: (text, reading, options) =>
Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)),
getKnownWordTier: (text, reading, options) =>
appState.ankiIntegration?.getKnownWordTier(text, reading, options) ?? null,
recordLookup: (hit) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
@@ -82,6 +82,73 @@ test('manual selection store persists overrides and matches later episodes in th
});
});
test('manual selection store applies a season-directory override when later episode guesses differ', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directory = '/Volumes/jellyfin/anime/toaru-kagaku-no-railgun/Season-2';
const selectedKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 10 - Critical.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 10 - Critical.mkv',
guess: {
title: 'Critical',
season: null,
episode: 10,
source: 'guessit',
},
});
await store.setOverride({
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
const laterEpisodeKey = buildCharacterDictionarySeriesKey({
mediaPath: `${directory}/A Certain Scientific Railgun S - 16 - Sisters.mkv`,
mediaTitle: 'A Certain Scientific Railgun S - 16 - Sisters.mkv',
guess: {
title: 'Sisters',
season: null,
episode: 16,
source: 'guessit',
},
});
assert.deepEqual(await store.getOverride(laterEpisodeKey), {
seriesKey: selectedKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store replaces older guessed keys for the same season directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const directoryKey = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
const firstKey = `${directoryKey}--critical`;
const laterKey = `${directoryKey}--sisters`;
await store.setOverride({
seriesKey: firstKey,
mediaId: 1057,
mediaTitle: 'Ippatsu Kiki Musume',
staleMediaIds: [],
});
await store.setOverride({
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
assert.deepEqual(await store.getOverride(firstKey), {
seriesKey: laterKey,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store resolves legacy unscoped override keys', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
@@ -171,6 +238,42 @@ test('manual selection store prefers exact scoped override over legacy fallback'
});
});
test('manual selection store prefers same-directory override over legacy fallback', async () => {
const userDataPath = makeTempDir();
const overrideDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(overrideDir, { recursive: true });
const directoryScope = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
fs.writeFileSync(
path.join(overrideDir, 'anilist-overrides.json'),
JSON.stringify({
overrides: [
{
seriesKey: 'sisters',
mediaId: 1057,
mediaTitle: 'Legacy fallback',
staleMediaIds: [],
},
{
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
},
],
}),
'utf8',
);
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
assert.deepEqual(await store.getOverride(`${directoryScope}--sisters`), {
seriesKey: `${directoryScope}--critical`,
mediaId: 16049,
mediaTitle: 'A Certain Scientific Railgun S',
staleMediaIds: [1057],
});
});
test('manual selection store keeps overrides separate for different season directories', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
@@ -107,6 +107,11 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
}
function getDirectoryScope(seriesKey: string): string | null {
const scopedSeparatorIndex = seriesKey.indexOf('--');
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
}
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
@@ -135,7 +140,23 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
for (const candidate of candidates) {
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
if (exactMatch) return exactMatch;
const directoryScope = getDirectoryScope(seriesKey);
if (directoryScope) {
const scopedMatches = overrides.filter(
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
);
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
const latest = scopedMatches.at(-1)!;
return {
...latest,
staleMediaIds: dedupeNumbers(scopedMatches.flatMap((entry) => entry.staleMediaIds)),
};
}
}
for (const candidate of candidates.slice(1)) {
const match = overrides.find((entry) => entry.seriesKey === candidate);
if (match) return match;
}
@@ -146,8 +167,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
if (!normalized) {
throw new Error('Invalid character dictionary manual selection.');
}
const remaining = readOverrides(filePath).filter(
(entry) => entry.seriesKey !== normalized.seriesKey,
const directoryScope = getDirectoryScope(normalized.seriesKey);
const remaining = readOverrides(filePath).filter((entry) =>
directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope
: entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
},
@@ -42,6 +42,12 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
deps.recordLookup(hit);
return hit;
},
...(deps.getKnownWordTier
? {
getKnownWordTier: (text, reading, options) =>
deps.getKnownWordTier!(text, reading, options),
}
: {}),
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
...(deps.getKnownWordsEnabled
? {
+59 -6
View File
@@ -2,6 +2,12 @@ import type { SessionHelpSection } from './session-help-sections';
export type SessionHelpSubtitleStyle = {
knownWordColor?: unknown;
knownWordMaturityColors?: {
new?: unknown;
learning?: unknown;
young?: unknown;
mature?: unknown;
};
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
@@ -13,10 +19,19 @@ export type SessionHelpSubtitleStyle = {
};
};
export type SessionHelpColorOptions = {
/** When true, known words are colored per Anki card maturity instead of one flat color. */
knownWordMaturityEnabled?: boolean;
};
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
knownWordMaturityNewColor: '#ee99a0',
knownWordMaturityLearningColor: '#b7bdf8',
knownWordMaturityYoungColor: '#91d7e3',
knownWordMaturityMatureColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
@@ -32,15 +47,53 @@ function normalizeColor(value: unknown, fallback: string): string {
return HEX_COLOR_RE.test(next) ? next : fallback;
}
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
function buildKnownWordRows(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions,
): SessionHelpSection['rows'] {
if (!options.knownWordMaturityEnabled) {
const knownWordColor = normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor);
return [{ shortcut: 'Known words', action: knownWordColor, color: knownWordColor }];
}
const maturityColors = style.knownWordMaturityColors;
const tiers: Array<{ label: string; value: unknown; fallback: string }> = [
{
label: 'Known words (new)',
value: maturityColors?.new,
fallback: FALLBACK_COLORS.knownWordMaturityNewColor,
},
{
label: 'Known words (learning)',
value: maturityColors?.learning,
fallback: FALLBACK_COLORS.knownWordMaturityLearningColor,
},
{
label: 'Known words (young)',
value: maturityColors?.young,
fallback: FALLBACK_COLORS.knownWordMaturityYoungColor,
},
{
label: 'Known words (mature)',
value: maturityColors?.mature,
fallback: FALLBACK_COLORS.knownWordMaturityMatureColor,
},
];
return tiers.map((tier) => {
const color = normalizeColor(tier.value, tier.fallback);
return { shortcut: tier.label, action: color, color };
});
}
export function buildColorSection(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions = {},
): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
...buildKnownWordRows(style, options),
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
+4 -1
View File
@@ -403,6 +403,7 @@ export function buildSessionHelpSections(input: {
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
knownWordMaturityEnabled?: boolean;
}): SessionHelpSection[] {
const sessionBindings = input.sessionBindings.filter((binding) => {
if (binding.actionType !== 'session-action') return true;
@@ -420,7 +421,9 @@ export function buildSessionHelpSections(input: {
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
}),
...buildFixedOverlaySections(),
buildColorSection(input.subtitleStyle ?? {}),
buildColorSection(input.subtitleStyle ?? {}, {
knownWordMaturityEnabled: input.knownWordMaturityEnabled,
}),
]);
}
+98
View File
@@ -9,7 +9,9 @@ import {
createSessionHelpModal,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
isKnownWordMaturityLegendEnabled,
} from './session-help.js';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
@@ -104,6 +106,101 @@ test('session help builds rows from canonical session bindings and fixed overlay
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
});
function booleanRuntimeOption(id: RuntimeOptionId, value: boolean): RuntimeOptionState {
return {
id,
label: id,
scope: 'subtitle',
valueType: 'boolean',
value,
allowedValues: [true, false],
requiresRestart: false,
};
}
test('maturity legend requires both known-word highlighting and maturity coloring', () => {
const highlightOn = booleanRuntimeOption('subtitle.annotation.knownWords.highlightEnabled', true);
const highlightOff = booleanRuntimeOption(
'subtitle.annotation.knownWords.highlightEnabled',
false,
);
const maturityOn = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', true);
const maturityOff = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOn]), true);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOff, maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOff]), false);
assert.equal(isKnownWordMaturityLegendEnabled([maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([]), false);
});
function colorLegendRows(input: Parameters<typeof buildSessionHelpSections>[0]) {
const sections = buildSessionHelpSections(input);
return sections.find((section) => section.title === 'Color legend')?.rows ?? [];
}
test('color legend shows the flat known-word color when maturity coloring is off', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
},
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[{ shortcut: 'Known words', action: '#a6da95', color: '#a6da95' }],
);
});
test('color legend swaps in maturity tiers when maturity coloring is on', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#f0c6c6',
},
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[
{ shortcut: 'Known words (new)', action: '#ee99a0', color: '#ee99a0' },
{ shortcut: 'Known words (learning)', action: '#b7bdf8', color: '#b7bdf8' },
{ shortcut: 'Known words (young)', action: '#91d7e3', color: '#91d7e3' },
{ shortcut: 'Known words (mature)', action: '#f0c6c6', color: '#f0c6c6' },
],
);
assert.ok(rows.some((row) => row.shortcut === 'N+1 words'));
});
test('color legend falls back to default maturity colors when overrides are invalid', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordMaturityColors: { new: 'not-a-color', learning: 42 },
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')).map((row) => row.color),
['#ee99a0', '#b7bdf8', '#91d7e3', '#a6da95'],
);
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
@@ -176,6 +273,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
getSubtitleSidebarSnapshot: async () => ({
config: { toggleKey: 'Backslash' },
}),
getRuntimeOptions: async () => [],
},
focus: () => {},
addEventListener: () => {},
+44 -10
View File
@@ -1,4 +1,5 @@
import type { ModalStateReader, RendererContext } from '../context';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
import {
buildSessionHelpSections,
type SessionHelpSection,
@@ -19,6 +20,32 @@ type SessionHelpBindingInfo = {
fallbackUnavailable: boolean;
};
/**
* Tiers only render when known-word highlighting is also on, matching
* getKnownWordMaturityEnabled in anki-integration/known-word-maturity.
*/
export function isKnownWordMaturityLegendEnabled(runtimeOptions: RuntimeOptionState[]): boolean {
const isOn = (id: RuntimeOptionId): boolean =>
runtimeOptions.some((option) => option.id === id && option.value === true);
return (
isOn('subtitle.annotation.knownWords.highlightEnabled') &&
isOn('subtitle.annotation.knownWords.maturityEnabled')
);
}
/**
* Maturity coloring is a live runtime toggle, so the color legend reads it from
* runtime options instead of the resolved subtitle style. A missing or failing
* runtime-options call falls back to the flat known-word color.
*/
async function readKnownWordMaturityEnabled(): Promise<boolean> {
try {
return isKnownWordMaturityLegendEnabled(await window.electronAPI.getRuntimeOptions());
} catch {
return false;
}
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
@@ -219,22 +246,29 @@ export function createSessionHelpModal(
async function render(): Promise<boolean> {
try {
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
]);
const [
sessionBindings,
styleConfig,
markWatchedKey,
subtitleSidebarToggleKey,
knownWordMaturityEnabled,
] = await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
readKnownWordMaturityEnabled(),
]);
helpSections = buildSessionHelpSections({
sessionBindings,
markWatchedKey,
subtitleSidebarToggleKey,
subtitleStyle: styleConfig ?? {},
knownWordMaturityEnabled,
});
applyFilterAndRender();
return true;
+8
View File
@@ -116,6 +116,10 @@ export type RendererState = {
subtitleSidebarPausedByHover: boolean;
knownWordColor: string;
knownWordMaturityNewColor: string;
knownWordMaturityLearningColor: string;
knownWordMaturityYoungColor: string;
knownWordMaturityMatureColor: string;
nPlusOneColor: string;
nameMatchEnabled: boolean;
nameMatchColor: string;
@@ -240,6 +244,10 @@ export function createRendererState(): RendererState {
subtitleSidebarPausedByHover: false,
knownWordColor: '#a6da95',
knownWordMaturityNewColor: '#ee99a0',
knownWordMaturityLearningColor: '#b7bdf8',
knownWordMaturityYoungColor: '#91d7e3',
knownWordMaturityMatureColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchEnabled: false,
nameMatchColor: '#f5bde6',
+42
View File
@@ -1503,6 +1503,24 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
color: var(--subtitle-known-word-color, #a6da95);
}
/* Anki maturity tiers ride on word-known and only override its color, so
hover/selection rules keyed on word-known keep applying. */
#subtitleRoot .word.word-known.word-maturity-new {
color: var(--subtitle-maturity-new-color, #ee99a0);
}
#subtitleRoot .word.word-known.word-maturity-learning {
color: var(--subtitle-maturity-learning-color, #b7bdf8);
}
#subtitleRoot .word.word-known.word-maturity-young {
color: var(--subtitle-maturity-young-color, #91d7e3);
}
#subtitleRoot .word.word-known.word-maturity-mature {
color: var(--subtitle-maturity-mature-color, #a6da95);
}
#subtitleRoot .word.word-n-plus-one {
color: var(--subtitle-n-plus-one-color, #c6a0f6);
}
@@ -1680,6 +1698,30 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
-webkit-text-fill-color: var(--subtitle-known-word-color, #a6da95) !important;
}
#subtitleRoot .word.word-known.word-maturity-new::selection,
#subtitleRoot .word.word-known.word-maturity-new .c::selection {
color: var(--subtitle-maturity-new-color, #ee99a0) !important;
-webkit-text-fill-color: var(--subtitle-maturity-new-color, #ee99a0) !important;
}
#subtitleRoot .word.word-known.word-maturity-learning::selection,
#subtitleRoot .word.word-known.word-maturity-learning .c::selection {
color: var(--subtitle-maturity-learning-color, #b7bdf8) !important;
-webkit-text-fill-color: var(--subtitle-maturity-learning-color, #b7bdf8) !important;
}
#subtitleRoot .word.word-known.word-maturity-young::selection,
#subtitleRoot .word.word-known.word-maturity-young .c::selection {
color: var(--subtitle-maturity-young-color, #91d7e3) !important;
-webkit-text-fill-color: var(--subtitle-maturity-young-color, #91d7e3) !important;
}
#subtitleRoot .word.word-known.word-maturity-mature::selection,
#subtitleRoot .word.word-known.word-maturity-mature .c::selection {
color: var(--subtitle-maturity-mature-color, #a6da95) !important;
-webkit-text-fill-color: var(--subtitle-maturity-mature-color, #a6da95) !important;
}
#subtitleRoot .word.word-n-plus-one::selection,
#subtitleRoot .word.word-n-plus-one .c::selection {
color: var(--subtitle-n-plus-one-color, #c6a0f6) !important;
@@ -204,3 +204,51 @@ test('computeWordClass skips frequency class when rank is out of topX', () => {
assert.equal(actual, 'word');
});
test('computeWordClass adds the maturity tier class alongside word-known', () => {
const token = createToken({
isKnown: true,
knownMaturity: 'mature',
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known word-maturity-mature');
});
test('computeWordClass keeps the plain known class when no maturity tier is set', () => {
const token = createToken({
isKnown: true,
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known');
});
test('computeWordClass composes maturity with JLPT classes', () => {
const token = createToken({
isKnown: true,
knownMaturity: 'young',
jlptLevel: 'N3',
surface: '猫',
});
assert.equal(computeWordClass(token), 'word word-known word-maturity-young word-jlpt-n3');
});
test('computeWordClass gives n+1 and name matches precedence over maturity', () => {
const nPlusOne = createToken({
isKnown: true,
knownMaturity: 'mature',
isNPlusOneTarget: true,
surface: '犬',
});
assert.equal(computeWordClass(nPlusOne), 'word word-n-plus-one');
const nameMatch = createToken({
isKnown: true,
knownMaturity: 'mature',
surface: 'アクア',
}) as MergedToken & { isNameMatch?: boolean };
nameMatch.isNameMatch = true;
assert.equal(computeWordClass(nameMatch, { nameMatchEnabled: true }), 'word word-name-match');
});
+67
View File
@@ -1445,3 +1445,70 @@ test('secondary subtitle root CSS caps height so hover-pause band stays a top st
assert.match(secondaryRootBlock, /max-height:\s*6em;/);
assert.match(secondaryRootBlock, /overflow:\s*hidden;/);
});
test('applySubtitleStyle sets known-word maturity color variables', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
knownWordMaturityColors: {
new: '#111111',
learning: '#222222',
young: '#333333',
mature: '#444444',
},
} as never);
const values = (subtitleRoot.style as unknown as { values?: Map<string, string> }).values;
assert.equal(values?.get('--subtitle-maturity-new-color'), '#111111');
assert.equal(values?.get('--subtitle-maturity-learning-color'), '#222222');
assert.equal(values?.get('--subtitle-maturity-young-color'), '#333333');
assert.equal(values?.get('--subtitle-maturity-mature-color'), '#444444');
} finally {
restoreDocument();
}
});
test('applySubtitleStyle falls back to default maturity colors', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({} as never);
const values = (subtitleRoot.style as unknown as { values?: Map<string, string> }).values;
assert.equal(values?.get('--subtitle-maturity-new-color'), '#ee99a0');
assert.equal(values?.get('--subtitle-maturity-learning-color'), '#b7bdf8');
assert.equal(values?.get('--subtitle-maturity-young-color'), '#91d7e3');
assert.equal(values?.get('--subtitle-maturity-mature-color'), '#a6da95');
} finally {
restoreDocument();
}
});

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