mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 16:49:51 -07:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fb212cd575
|
|||
|
998e4b52af
|
|||
|
765df15048
|
|||
|
c1db917210
|
|||
|
f0e1cd6548
|
|||
|
a173b108d1
|
|||
|
a39ca2cbac
|
|||
|
8a8a700ccb
|
|||
|
e223cf9b71
|
|||
|
c718bb8b34
|
|||
|
177f7e5fd6
|
|||
| 20b50ec4e1 | |||
| deae61b211 | |||
|
44959ed282
|
|||
| 2398f5c030 | |||
|
8711cf1a48
|
@@ -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 video.mkv # launch mpv with SubMiner
|
||||||
subminer /path/to/dir # pick a file with fzf
|
subminer /path/to/dir # pick a file with fzf
|
||||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
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.
|
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 |
|
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
| [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 |
|
| [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](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 |
|
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.9",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.28",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.8.2",
|
"electron-builder": "26.15.3",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.28.0",
|
||||||
@@ -35,20 +35,19 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.8.2",
|
"app-builder-lib": "26.15.3",
|
||||||
"electron-builder-squirrel-windows": "26.8.2",
|
"brace-expansion": "5.0.8",
|
||||||
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
|
"ip-address": "10.2.0",
|
||||||
|
"js-yaml": "4.3.0",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.3",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.16",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
},
|
},
|
||||||
"packages": {
|
"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/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=="],
|
"@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/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=="],
|
"@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-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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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": ["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-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=="],
|
"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.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=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
||||||
|
|
||||||
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
|
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"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=="],
|
"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=="],
|
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||||
|
|
||||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"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=="],
|
"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=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
@@ -515,29 +480,23 @@
|
|||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"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-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-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-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=="],
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
|
||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||||
|
|
||||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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-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-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-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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"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=="],
|
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||||
|
|
||||||
@@ -689,31 +624,33 @@
|
|||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
|
||||||
|
|
||||||
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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": ["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.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
|
||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
|
||||||
|
|
||||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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/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/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/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/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=="],
|
"@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/@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@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/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/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=="],
|
"@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/@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
|
||||||
|
|
||||||
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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.
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: app
|
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,4 @@
|
|||||||
|
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.
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: overlay
|
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.
|
||||||
|
|||||||
@@ -433,6 +433,12 @@
|
|||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"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.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"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": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -569,6 +575,8 @@
|
|||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"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.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
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
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
|
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ Focused commands:
|
|||||||
bun run test:config # Source-level config schema/validation tests
|
bun run test:config # Source-level config schema/validation tests
|
||||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
||||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
||||||
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
bun run test:env # Launcher smoke + Lua plugin gate
|
||||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||||
bun run test:scripts # Bun-managed scripts/** test lane
|
bun run test:scripts # Bun-managed scripts/** test lane
|
||||||
|
|||||||
@@ -73,9 +73,12 @@ 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:
|
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
|
- **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)
|
- **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 is shown first
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,12 @@
|
|||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"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.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"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": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -569,6 +575,8 @@
|
|||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"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.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"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
|
"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
|
||||||
|
|||||||
@@ -43,6 +43,32 @@ 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.
|
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`, `is:new`) - no extra card data is downloaded.
|
||||||
|
2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed).
|
||||||
|
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 (re)learning cards |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
## Character-Name Highlighting
|
## 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.
|
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 +157,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:
|
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.highlightEnabled` (`On` / `Off`)
|
||||||
|
- `ankiConnect.knownWords.maturityEnabled` (`On` / `Off`)
|
||||||
- `ankiConnect.knownWords.matchMode`
|
- `ankiConnect.knownWords.matchMode`
|
||||||
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
|
- `ankiConnect.nPlusOne.enabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||||
@@ -146,6 +173,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
|
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
|
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)
|
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)
|
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
@@ -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 -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
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 app --setup # Open first-run setup popup
|
||||||
subminer --version # Print the launcher's version
|
subminer --version # Print the launcher's version
|
||||||
subminer -v # Same as above
|
subminer -v # Same as above
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
|||||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||||
- `src/main/runtime/composers/` owns larger domain compositions.
|
- `src/main/runtime/composers/` owns larger domain compositions.
|
||||||
|
- `src/main.ts` call sites invoke configured runtime handlers and runtime-object methods directly;
|
||||||
|
do not add local pass-through wrappers around them.
|
||||||
|
|
||||||
## Architecture Intent
|
## Architecture Intent
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Domain Ownership
|
# Domain Ownership
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-05-23
|
Last verified: 2026-07-15
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: you need to find the owner module for a behavior or test surface
|
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
|
## 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`
|
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||||
- MPV runtime and protocol: `src/core/services/mpv*.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/`
|
- 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/`
|
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
- 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/`
|
- Public docs site: `docs-site/`
|
||||||
|
|
||||||
## Shared Contract Entry Points
|
## Shared Contract Entry Points
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Read when: selecting the right verification lane for a change
|
|||||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||||
`--single-process` restores the shared-process mode for debugging.
|
`--single-process` restores the shared-process mode for debugging.
|
||||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||||
unit, `scripts/**`, and the compiled runtime-compat slice.
|
unit, and `scripts/**`.
|
||||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||||
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
||||||
there instead of copying them into caller workflows.
|
there instead of copying them into caller workflows.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '../picker.js';
|
} from '../picker.js';
|
||||||
import {
|
import {
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
|
findPreviousEpisode,
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
listSeasonDirs,
|
listSeasonDirs,
|
||||||
materializeCoverArt,
|
materializeCoverArt,
|
||||||
@@ -23,6 +24,139 @@ import {
|
|||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function checkPickerDependencies(args: Args): void {
|
||||||
if (args.useRofi) {
|
if (args.useRofi) {
|
||||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||||
@@ -142,7 +276,7 @@ function browseEpisodes(
|
|||||||
if (seasons.length > 1) {
|
if (seasons.length > 1) {
|
||||||
const idx = pickIndex(
|
const idx = pickIndex(
|
||||||
seasons.map((season) => season.name),
|
seasons.map((season) => season.name),
|
||||||
`${entry.displayName} — Season`,
|
`${entry.displayName}: Season`,
|
||||||
args.useRofi,
|
args.useRofi,
|
||||||
themePath,
|
themePath,
|
||||||
);
|
);
|
||||||
@@ -155,7 +289,9 @@ function browseEpisodes(
|
|||||||
return pickEpisodeFromDir(dir, context);
|
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;
|
const { args, scriptPath } = context;
|
||||||
|
|
||||||
checkPickerDependencies(args);
|
checkPickerDependencies(args);
|
||||||
@@ -198,14 +334,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
|||||||
const lastExists = fs.existsSync(lastPath);
|
const lastExists = fs.existsSync(lastPath);
|
||||||
const nextEpisode = findNextEpisode(lastPath);
|
const nextEpisode = findNextEpisode(lastPath);
|
||||||
|
|
||||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
const actions: HistorySessionMenuAction[] = [];
|
||||||
if (lastExists) {
|
if (lastExists) {
|
||||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` });
|
||||||
}
|
}
|
||||||
if (nextEpisode) {
|
if (nextEpisode) {
|
||||||
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisode)}` });
|
||||||
}
|
}
|
||||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||||
|
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
|
||||||
|
|
||||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||||
const actionIdx = pickIndex(
|
const actionIdx = pickIndex(
|
||||||
@@ -219,10 +356,15 @@ export async function runHistoryCommand(context: LauncherCommandContext): Promis
|
|||||||
|
|
||||||
switch (actions[actionIdx]!.kind) {
|
switch (actions[actionIdx]!.kind) {
|
||||||
case 'replay':
|
case 'replay':
|
||||||
return lastPath;
|
return { entry, videoPath: lastPath, themePath, entryIcon };
|
||||||
case 'next':
|
case 'next':
|
||||||
return nextEpisode;
|
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
||||||
case 'browse':
|
case 'browse': {
|
||||||
return browseEpisodes(entry, context, themePath);
|
const videoPath = browseEpisodes(entry, context, themePath);
|
||||||
|
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
||||||
|
}
|
||||||
|
case 'previous':
|
||||||
|
case 'quit':
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { 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 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']);
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
import { registerCleanup, runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state } from '../mpv.js';
|
||||||
|
|
||||||
function createContext(): LauncherCommandContext {
|
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 () => {
|
test('youtube playback launches overlay with app-owned youtube flow args', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
|||||||
|
|
||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
|
const cleanupRegisteredProcessAdapters = new WeakSet<LauncherCommandContext['processAdapter']>();
|
||||||
|
|
||||||
function getLauncherConfigDir(): string {
|
function getLauncherConfigDir(): string {
|
||||||
return getDefaultConfigDir({
|
return getDefaultConfigDir({
|
||||||
@@ -92,8 +93,10 @@ async function chooseTarget(
|
|||||||
return { target: selected, kind: 'file' };
|
return { target: selected, kind: 'file' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCleanup(context: LauncherCommandContext): void {
|
export function registerCleanup(context: LauncherCommandContext): void {
|
||||||
const { args, processAdapter } = context;
|
const { args, processAdapter } = context;
|
||||||
|
if (cleanupRegisteredProcessAdapters.has(processAdapter)) return;
|
||||||
|
|
||||||
processAdapter.onSignal('SIGINT', () => {
|
processAdapter.onSignal('SIGINT', () => {
|
||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.exit(130);
|
processAdapter.exit(130);
|
||||||
@@ -102,6 +105,7 @@ function registerCleanup(context: LauncherCommandContext): void {
|
|||||||
stopOverlay(args);
|
stopOverlay(args);
|
||||||
processAdapter.exit(143);
|
processAdapter.exit(143);
|
||||||
});
|
});
|
||||||
|
cleanupRegisteredProcessAdapters.add(processAdapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
||||||
|
|||||||
@@ -130,3 +130,46 @@ export function findNextEpisode(lastPath: string): string | null {
|
|||||||
|
|
||||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Database } from 'bun:sqlite';
|
|||||||
import {
|
import {
|
||||||
detectImageExtension,
|
detectImageExtension,
|
||||||
findNextEpisode,
|
findNextEpisode,
|
||||||
|
findPreviousEpisode,
|
||||||
groupHistoryBySeries,
|
groupHistoryBySeries,
|
||||||
isReadonlyWalRetryError,
|
isReadonlyWalRetryError,
|
||||||
listSeasonDirs,
|
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');
|
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||||
|
|
||||||
function createHistoryDb(
|
function createHistoryDb(
|
||||||
|
|||||||
+8
-6
@@ -21,7 +21,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
|||||||
import { runLogsCommand } from './commands/logs-command.js';
|
import { runLogsCommand } from './commands/logs-command.js';
|
||||||
import { runStatsCommand } from './commands/stats-command.js';
|
import { runStatsCommand } from './commands/stats-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-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 { runSyncCommand } from './commands/sync-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
import { runUpdateCommand } from './commands/update-command.js';
|
import { runUpdateCommand } from './commands/update-command.js';
|
||||||
@@ -149,13 +149,15 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (appContext.args.history) {
|
if (appContext.args.history) {
|
||||||
const selected = await runHistoryCommand(appContext);
|
const played = await runHistorySession(appContext, async (videoPath) => {
|
||||||
if (!selected) {
|
appContext.args.target = videoPath;
|
||||||
|
appContext.args.targetKind = 'file';
|
||||||
|
await runPlaybackCommand(appContext);
|
||||||
|
});
|
||||||
|
if (!played) {
|
||||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
appContext.args.target = selected;
|
return;
|
||||||
appContext.args.targetKind = 'file';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await runPlaybackCommand(appContext);
|
await runPlaybackCommand(appContext);
|
||||||
|
|||||||
@@ -632,8 +632,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
|
|||||||
// Only the ended session's count is adopted; the active session's slice
|
// Only the ended session's count is adopted; the active session's slice
|
||||||
// is re-added when that session finalizes and syncs.
|
// is re-added when that session finalizes and syncs.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
queryOne<{ frequency: number }>(
|
||||||
?.frequency,
|
localPath,
|
||||||
|
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
||||||
|
)?.frequency,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -650,8 +652,10 @@ test('adopted word frequency excludes active-session counts that merge later', (
|
|||||||
assert.equal(second.sessionsAlreadyPresent, 1);
|
assert.equal(second.sessionsAlreadyPresent, 1);
|
||||||
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
queryOne<{ frequency: number }>(
|
||||||
?.frequency,
|
localPath,
|
||||||
|
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
||||||
|
)?.frequency,
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+17
-20
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"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",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -47,13 +47,9 @@
|
|||||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||||
"docs:test": "bun run --cwd docs-site test",
|
"docs:test": "bun run --cwd docs-site test",
|
||||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||||
"test:config:src": "bun scripts/run-test-lane.mjs config",
|
|
||||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
|
||||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
|
||||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||||
@@ -64,15 +60,13 @@
|
|||||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||||
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
"test:scripts": "bun scripts/run-test-lane.mjs scripts",
|
||||||
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
"test:stats": "bun scripts/run-test-lane.mjs stats",
|
||||||
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
|
||||||
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
|
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full",
|
||||||
"test:runtime:compat": "bun run tsc && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
|
||||||
"test:node:compat": "bun run test:runtime:compat",
|
|
||||||
"test": "bun run test:fast",
|
"test": "bun run test:fast",
|
||||||
"test:config": "bun run test:config:src",
|
"test:config": "bun scripts/run-test-lane.mjs config",
|
||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts && bun run test:runtime:compat",
|
"test:fast": "bun run test:src && bun run test:launcher:unit:src && bun run test:scripts",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
@@ -88,13 +82,16 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.8.2",
|
"app-builder-lib": "26.15.3",
|
||||||
"electron-builder-squirrel-windows": "26.8.2",
|
"brace-expansion": "5.0.8",
|
||||||
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
|
"ip-address": "10.2.0",
|
||||||
|
"js-yaml": "4.3.0",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.3",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.16",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7"
|
"tmp": "0.2.7"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -115,7 +112,7 @@
|
|||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.9",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.28",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
@@ -126,10 +123,10 @@
|
|||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.8.2",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.28.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,29 +6,50 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Sync Stats & History**
|
- **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.
|
- 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.
|
||||||
- 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.
|
- 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 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.
|
- 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**
|
- **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.
|
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
|
||||||
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
|
- 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
|
### 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
|
### 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.
|
- **Word Highlighting Accuracy**
|
||||||
- **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.
|
- 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.
|
||||||
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
|
||||||
|
- **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
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- 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(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(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
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,6 @@ export const testLanes: Record<string, TestLane> = {
|
|||||||
'bun-src-full': {
|
'bun-src-full': {
|
||||||
roots: ['src'],
|
roots: ['src'],
|
||||||
include: ['.test.ts', '.type-test.ts'],
|
include: ['.test.ts', '.type-test.ts'],
|
||||||
// Node-compat suites; their dist builds run via test:runtime:compat.
|
|
||||||
exclude: [
|
|
||||||
'src/core/services/anki-jimaku-ipc.test.ts',
|
|
||||||
'src/core/services/ipc.test.ts',
|
|
||||||
'src/core/services/overlay-manager.test.ts',
|
|
||||||
'src/main/config-validation.test.ts',
|
|
||||||
'src/main/runtime/registry.test.ts',
|
|
||||||
'src/main/runtime/startup-config.test.ts',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
roots: ['src/config'],
|
roots: ['src/config'],
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { once } from 'node:events';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
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);
|
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 () => {
|
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
import { MpvClient } from './types/runtime';
|
import { MpvClient } from './types/runtime';
|
||||||
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
|
||||||
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
import type { NotificationType, OverlayNotificationPayload } from './types/notification';
|
||||||
@@ -461,6 +462,7 @@ export class AnkiIntegration {
|
|||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
|
notifyUnavailable: (message) => this.showStatusNotification(message),
|
||||||
logInfo: (message, ...args) => log.info(message, ...args),
|
logInfo: (message, ...args) => log.info(message, ...args),
|
||||||
logWarn: (message, ...args) => log.warn(message, ...args),
|
logWarn: (message, ...args) => log.warn(message, ...args),
|
||||||
logError: (message, ...args) => log.error(message, ...args),
|
logError: (message, ...args) => log.error(message, ...args),
|
||||||
@@ -731,6 +733,14 @@ export class AnkiIntegration {
|
|||||||
return this.knownWordCache.isKnownWord(text, reading, options);
|
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 {
|
getKnownWordMatchMode(): NPlusOneMatchMode {
|
||||||
return this.config.knownWords?.matchMode ?? DEFAULT_ANKI_CONNECT_CONFIG.knownWords.matchMode;
|
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);
|
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;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
notifyUnavailable?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnkiConnectProxyServer {
|
export class AnkiConnectProxyServer {
|
||||||
@@ -78,7 +79,23 @@ export class AnkiConnectProxyServer {
|
|||||||
void this.handleRequest(req, res, options.upstreamUrl);
|
void this.handleRequest(req, res, options.upstreamUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const server = this.server;
|
||||||
this.server.on('error', (error) => {
|
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.rejectReady?.(error as Error);
|
||||||
this.resolveReady = null;
|
this.resolveReady = null;
|
||||||
this.rejectReady = null;
|
this.rejectReady = null;
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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}',
|
||||||
|
);
|
||||||
|
|
||||||
|
const customThreshold: AnkiConnectConfig = {
|
||||||
|
knownWords: {
|
||||||
|
highlightEnabled: true,
|
||||||
|
maturityEnabled: true,
|
||||||
|
matureThresholdDays: 30,
|
||||||
|
refreshMinutes: 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
getKnownWordCacheLifecycleConfig(customThreshold),
|
||||||
|
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
|
||||||
|
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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 {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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 {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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 {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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 {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
|
||||||
|
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');
|
||||||
|
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;
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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 {
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -314,7 +314,7 @@ test('KnownWordCacheManager refresh incrementally reconciles deleted and edited
|
|||||||
version: number;
|
version: number;
|
||||||
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
notes?: Record<string, Array<{ word: string; reading: string | null }>>;
|
||||||
};
|
};
|
||||||
assert.equal(persisted.version, 3);
|
assert.equal(persisted.version, 4);
|
||||||
assert.deepEqual(persisted.notes, {
|
assert.deepEqual(persisted.notes, {
|
||||||
'1': [{ word: '鳥', reading: null }],
|
'1': [{ word: '鳥', reading: null }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import path from 'path';
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getConfiguredWordFieldName } from '../anki-field-config';
|
import { getConfiguredWordFieldName } from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig } from '../types/anki';
|
||||||
|
import type { KnownWordMaturityTier } from '../types/subtitle';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
classifyKnownWordNoteTier,
|
||||||
|
fetchKnownWordMaturityTierSets,
|
||||||
|
getKnownWordMaturityEnabled,
|
||||||
|
getMatureIntervalThresholdDays,
|
||||||
|
maxKnownWordMaturityTier,
|
||||||
|
sanitizeKnownWordMaturityTier,
|
||||||
|
} from './known-word-maturity';
|
||||||
import {
|
import {
|
||||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||||
KnownWordEntry,
|
KnownWordEntry,
|
||||||
@@ -62,11 +71,17 @@ export function getKnownWordCacheScopeForConfig(config: AnkiConnectConfig): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): string {
|
||||||
return JSON.stringify({
|
const payload: Record<string, unknown> = {
|
||||||
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
|
refreshMinutes: getKnownWordCacheRefreshIntervalMinutes(config),
|
||||||
scope: getKnownWordCacheScopeForConfig(config),
|
scope: getKnownWordCacheScopeForConfig(config),
|
||||||
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
||||||
});
|
};
|
||||||
|
// The maturity field is only added while enabled so persisted caches from
|
||||||
|
// before the feature existed (or with it off) keep their identity.
|
||||||
|
if (getKnownWordMaturityEnabled(config)) {
|
||||||
|
payload.maturity = getMatureIntervalThresholdDays(config);
|
||||||
|
}
|
||||||
|
return JSON.stringify(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnownWordCacheNoteInfo {
|
export interface KnownWordCacheNoteInfo {
|
||||||
@@ -96,7 +111,19 @@ interface KnownWordCacheStateV3 {
|
|||||||
readonly notes: Record<string, KnownWordEntry[]>;
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KnownWordCacheState = KnownWordCacheStateV1 | KnownWordCacheStateV2 | KnownWordCacheStateV3;
|
interface KnownWordCacheStateV4 {
|
||||||
|
readonly version: 4;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
|
readonly tiers: Record<string, KnownWordMaturityTier>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownWordCacheState =
|
||||||
|
| KnownWordCacheStateV1
|
||||||
|
| KnownWordCacheStateV2
|
||||||
|
| KnownWordCacheStateV3
|
||||||
|
| KnownWordCacheStateV4;
|
||||||
|
|
||||||
const NO_READING_KEY = '';
|
const NO_READING_KEY = '';
|
||||||
|
|
||||||
@@ -125,12 +152,13 @@ type KnownWordQueryScope = {
|
|||||||
export class KnownWordCacheManager {
|
export class KnownWordCacheManager {
|
||||||
private knownWordsLastRefreshedAtMs = 0;
|
private knownWordsLastRefreshedAtMs = 0;
|
||||||
private knownWordsStateKey = '';
|
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.
|
// entries fail open: the word matches regardless of the token's reading.
|
||||||
private wordReadingCounts = new Map<string, Map<string, number>>();
|
private wordReadingNoteIds = new Map<string, Map<string, Set<number>>>();
|
||||||
// hiragana reading → note count, so kana tokens still match by reading alone.
|
// hiragana reading → note ids, so kana tokens still match by reading alone.
|
||||||
private readingCounts = new Map<string, number>();
|
private readingNoteIds = new Map<string, Set<number>>();
|
||||||
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
private noteEntriesById = new Map<number, KnownWordEntry[]>();
|
||||||
|
private noteTierById = new Map<number, KnownWordMaturityTier>();
|
||||||
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
private knownWordsRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
private knownWordsRefreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private isRefreshingKnownWords = false;
|
private isRefreshingKnownWords = false;
|
||||||
@@ -156,7 +184,7 @@ export class KnownWordCacheManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownReadings = this.wordReadingCounts.get(normalized);
|
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
||||||
if (knownReadings && knownReadings.size > 0) {
|
if (knownReadings && knownReadings.size > 0) {
|
||||||
const normalizedReading =
|
const normalizedReading =
|
||||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||||
@@ -168,7 +196,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Callers that look up a kanji token's reading (not subtitle text) must
|
// 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
|
// every note including kanji words, so 渓谷's けいこく would match a
|
||||||
// mined 警告/けいこく.
|
// mined 警告/けいこく.
|
||||||
if (options?.allowReadingOnlyMatch === false) {
|
if (options?.allowReadingOnlyMatch === false) {
|
||||||
@@ -182,7 +210,73 @@ export class KnownWordCacheManager {
|
|||||||
if ([...hiragana].length === 1) {
|
if ([...hiragana].length === 1) {
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeKnownWordForLookup(text);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
||||||
|
if (knownReadings && knownReadings.size > 0) {
|
||||||
|
const normalizedReading =
|
||||||
|
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||||
|
let tier: KnownWordMaturityTier | null = null;
|
||||||
|
if (normalizedReading.length === 0) {
|
||||||
|
for (const noteIds of knownReadings.values()) {
|
||||||
|
tier = this.maxTierForNotes(tier, noteIds);
|
||||||
|
}
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
const noReadingNotes = knownReadings.get(NO_READING_KEY);
|
||||||
|
if (noReadingNotes) {
|
||||||
|
tier = this.maxTierForNotes(tier, noReadingNotes);
|
||||||
|
}
|
||||||
|
const exactReadingNotes = knownReadings.get(normalizedReading);
|
||||||
|
if (exactReadingNotes) {
|
||||||
|
tier = this.maxTierForNotes(tier, exactReadingNotes);
|
||||||
|
}
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.allowReadingOnlyMatch === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiragana = convertKatakanaToHiragana(normalized);
|
||||||
|
if ([...hiragana].length === 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const readingNotes = this.readingNoteIds.get(hiragana);
|
||||||
|
return readingNotes ? this.maxTierForNotes(null, readingNotes) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
refresh(force = false): Promise<void> {
|
||||||
@@ -229,7 +323,7 @@ export class KnownWordCacheManager {
|
|||||||
let didMutateCache = false;
|
let didMutateCache = false;
|
||||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
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();
|
this.clearKnownWordCacheState();
|
||||||
}
|
}
|
||||||
if (!this.knownWordsStateKey) {
|
if (!this.knownWordsStateKey) {
|
||||||
@@ -247,6 +341,15 @@ export class KnownWordCacheManager {
|
|||||||
return didMutateCache;
|
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) {
|
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||||
this.knownWordsLastRefreshedAtMs = Date.now();
|
this.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
}
|
}
|
||||||
@@ -290,6 +393,21 @@ export class KnownWordCacheManager {
|
|||||||
this.isRefreshingKnownWords = true;
|
this.isRefreshingKnownWords = true;
|
||||||
try {
|
try {
|
||||||
const noteFieldsById = await this.fetchKnownWordNoteFieldsById();
|
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);
|
const currentNoteIds = Array.from(noteFieldsById.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
if (this.noteEntriesById.size === 0) {
|
if (this.noteEntriesById.size === 0) {
|
||||||
@@ -316,13 +434,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.knownWordsLastRefreshedAtMs = Date.now();
|
||||||
this.knownWordsStateKey = frozenStateKey;
|
this.knownWordsStateKey = frozenStateKey;
|
||||||
this.persistKnownWordCacheState();
|
this.persistKnownWordCacheState();
|
||||||
log.info(
|
log.info(
|
||||||
'Known-word cache refreshed',
|
'Known-word cache refreshed',
|
||||||
`noteCount=${currentNoteIds.length}`,
|
`noteCount=${currentNoteIds.length}`,
|
||||||
`wordCount=${this.wordReadingCounts.size}`,
|
`wordCount=${this.wordReadingNoteIds.size}`,
|
||||||
|
tierSets
|
||||||
|
? `maturityTiers=${this.noteTierById.size}`
|
||||||
|
: maturityFetchFailed
|
||||||
|
? 'maturityTiers=fetch-failed'
|
||||||
|
: 'maturityTiers=off',
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
log.warn('Failed to refresh known-word cache:', (error as Error).message);
|
||||||
@@ -337,6 +467,10 @@ export class KnownWordCacheManager {
|
|||||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isMaturityTrackingEnabled(): boolean {
|
||||||
|
return getKnownWordMaturityEnabled(this.deps.getConfig());
|
||||||
|
}
|
||||||
|
|
||||||
private shouldAddMinedWordsImmediately(): boolean {
|
private shouldAddMinedWordsImmediately(): boolean {
|
||||||
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
return this.deps.getConfig().knownWords?.addMinedWordsImmediately !== false;
|
||||||
}
|
}
|
||||||
@@ -593,12 +727,13 @@ export class KnownWordCacheManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.removeEntriesFromCounts(previousEntries);
|
this.removeEntriesFromIndexes(noteId, previousEntries);
|
||||||
if (normalizedEntries.length > 0) {
|
if (normalizedEntries.length > 0) {
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addEntriesToCounts(normalizedEntries);
|
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||||
} else {
|
} else {
|
||||||
this.noteEntriesById.delete(noteId);
|
this.noteEntriesById.delete(noteId);
|
||||||
|
this.noteTierById.delete(noteId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -609,54 +744,68 @@ export class KnownWordCacheManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.noteEntriesById.delete(noteId);
|
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) {
|
for (const entry of entries) {
|
||||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
let readings = this.wordReadingCounts.get(entry.word);
|
let readings = this.wordReadingNoteIds.get(entry.word);
|
||||||
if (!readings) {
|
if (!readings) {
|
||||||
readings = new Map();
|
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) {
|
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) {
|
for (const entry of entries) {
|
||||||
const readingKey = entry.reading ?? NO_READING_KEY;
|
const readingKey = entry.reading ?? NO_READING_KEY;
|
||||||
const readings = this.wordReadingCounts.get(entry.word);
|
const readings = this.wordReadingNoteIds.get(entry.word);
|
||||||
if (readings) {
|
if (readings) {
|
||||||
const nextCount = (readings.get(readingKey) ?? 0) - 1;
|
const noteIds = readings.get(readingKey);
|
||||||
if (nextCount > 0) {
|
if (noteIds) {
|
||||||
readings.set(readingKey, nextCount);
|
noteIds.delete(noteId);
|
||||||
} else {
|
if (noteIds.size === 0) {
|
||||||
readings.delete(readingKey);
|
readings.delete(readingKey);
|
||||||
if (readings.size === 0) {
|
if (readings.size === 0) {
|
||||||
this.wordReadingCounts.delete(entry.word);
|
this.wordReadingNoteIds.delete(entry.word);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entry.reading) {
|
if (entry.reading) {
|
||||||
const nextReadingCount = (this.readingCounts.get(entry.reading) ?? 0) - 1;
|
const readingNotes = this.readingNoteIds.get(entry.reading);
|
||||||
if (nextReadingCount > 0) {
|
if (readingNotes) {
|
||||||
this.readingCounts.set(entry.reading, nextReadingCount);
|
readingNotes.delete(noteId);
|
||||||
} else {
|
if (readingNotes.size === 0) {
|
||||||
this.readingCounts.delete(entry.reading);
|
this.readingNoteIds.delete(entry.reading);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearInMemoryState(): void {
|
private clearInMemoryState(): void {
|
||||||
this.wordReadingCounts = new Map();
|
this.wordReadingNoteIds = new Map();
|
||||||
this.readingCounts = new Map();
|
this.readingNoteIds = new Map();
|
||||||
this.noteEntriesById = new Map();
|
this.noteEntriesById = new Map();
|
||||||
|
this.noteTierById = new Map();
|
||||||
this.knownWordsLastRefreshedAtMs = 0;
|
this.knownWordsLastRefreshedAtMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,7 +838,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
if (parsed.version === 3) {
|
if (parsed.version === 3 || parsed.version === 4) {
|
||||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
@@ -700,7 +849,16 @@ export class KnownWordCacheManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addEntriesToCounts(normalizedEntries);
|
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||||
|
}
|
||||||
|
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.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||||
this.knownWordsStateKey = parsed.scope;
|
this.knownWordsStateKey = parsed.scope;
|
||||||
@@ -723,7 +881,7 @@ export class KnownWordCacheManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
this.addEntriesToCounts(normalizedEntries);
|
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||||
}
|
}
|
||||||
this.knownWordsStateKey = parsed.scope;
|
this.knownWordsStateKey = parsed.scope;
|
||||||
return;
|
return;
|
||||||
@@ -741,17 +899,23 @@ export class KnownWordCacheManager {
|
|||||||
private persistKnownWordCacheState(): void {
|
private persistKnownWordCacheState(): void {
|
||||||
try {
|
try {
|
||||||
const notes: Record<string, KnownWordEntry[]> = {};
|
const notes: Record<string, KnownWordEntry[]> = {};
|
||||||
|
const tiers: Record<string, KnownWordMaturityTier> = {};
|
||||||
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
for (const [noteId, entries] of this.noteEntriesById.entries()) {
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
notes[String(noteId)] = entries;
|
notes[String(noteId)] = entries;
|
||||||
|
const tier = this.noteTierById.get(noteId);
|
||||||
|
if (tier) {
|
||||||
|
tiers[String(noteId)] = tier;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: KnownWordCacheStateV3 = {
|
const state: KnownWordCacheStateV4 = {
|
||||||
version: 3,
|
version: 4,
|
||||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||||
scope: this.knownWordsStateKey,
|
scope: this.knownWordsStateKey,
|
||||||
notes,
|
notes,
|
||||||
|
tiers,
|
||||||
};
|
};
|
||||||
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
fs.writeFileSync(this.statePath, JSON.stringify(state), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -762,18 +926,33 @@ export class KnownWordCacheManager {
|
|||||||
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
if (candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3) {
|
if (
|
||||||
|
candidate.version !== 1 &&
|
||||||
|
candidate.version !== 2 &&
|
||||||
|
candidate.version !== 3 &&
|
||||||
|
candidate.version !== 4
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||||
if (typeof candidate.scope !== 'string') return false;
|
if (typeof candidate.scope !== 'string') return false;
|
||||||
if (candidate.version !== 3) {
|
if (candidate.version === 1 || candidate.version === 2) {
|
||||||
if (!Array.isArray(candidate.words)) return false;
|
if (!Array.isArray(candidate.words)) return false;
|
||||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidate.version === 2 || candidate.version === 3) {
|
if (candidate.version === 4) {
|
||||||
|
// Per-tier values are sanitized entry-by-entry at load time.
|
||||||
|
if (
|
||||||
|
typeof candidate.tiers !== 'object' ||
|
||||||
|
candidate.tiers === null ||
|
||||||
|
Array.isArray(candidate.tiers)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
|
||||||
if (
|
if (
|
||||||
typeof candidate.notes !== 'object' ||
|
typeof candidate.notes !== 'object' ||
|
||||||
candidate.notes === null ||
|
candidate.notes === null ||
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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');
|
||||||
|
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21');
|
||||||
|
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');
|
||||||
|
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30');
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
import type { KnownWordMaturityTier } from '../types/subtitle';
|
||||||
|
|
||||||
|
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function buildKnownWordMaturityTierQueries(
|
||||||
|
scopeQuery: string,
|
||||||
|
thresholdDays: number,
|
||||||
|
): KnownWordMaturityTierQueries {
|
||||||
|
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
|
||||||
|
return {
|
||||||
|
mature: `${prefix}prop:ivl>=${thresholdDays}`,
|
||||||
|
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -112,8 +112,20 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
|||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const valueNode = propertyValue(property);
|
const valueNode = propertyValue(property);
|
||||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
if (key === 'enabled') {
|
||||||
canonicalNPlusOneValues.set(key, value);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||||
|
|||||||
@@ -2182,6 +2182,7 @@ test('runtime options registry is centralized', () => {
|
|||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
'anki.autoUpdateNewCards',
|
'anki.autoUpdateNewCards',
|
||||||
'subtitle.annotation.knownWords.highlightEnabled',
|
'subtitle.annotation.knownWords.highlightEnabled',
|
||||||
|
'subtitle.annotation.knownWords.maturityEnabled',
|
||||||
'subtitle.annotation.nPlusOne',
|
'subtitle.annotation.nPlusOne',
|
||||||
'subtitle.annotation.jlpt',
|
'subtitle.annotation.jlpt',
|
||||||
'subtitle.annotation.frequency',
|
'subtitle.annotation.frequency',
|
||||||
@@ -2462,6 +2463,37 @@ test('resolves duplicate ankiConnect nPlusOne objects without rewriting config',
|
|||||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
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', () => {
|
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
},
|
},
|
||||||
knownWords: {
|
knownWords: {
|
||||||
highlightEnabled: false,
|
highlightEnabled: false,
|
||||||
|
maturityEnabled: false,
|
||||||
|
matureThresholdDays: 21,
|
||||||
refreshMinutes: 1440,
|
refreshMinutes: 1440,
|
||||||
addMinedWordsImmediately: true,
|
addMinedWordsImmediately: true,
|
||||||
matchMode: 'headword',
|
matchMode: 'headword',
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
backdropFilter: 'blur(6px)',
|
backdropFilter: 'blur(6px)',
|
||||||
nPlusOneColor: '#c6a0f6',
|
nPlusOneColor: '#c6a0f6',
|
||||||
knownWordColor: '#a6da95',
|
knownWordColor: '#a6da95',
|
||||||
|
knownWordMaturityColors: {
|
||||||
|
new: '#ee99a0',
|
||||||
|
learning: '#b7bdf8',
|
||||||
|
young: '#91d7e3',
|
||||||
|
mature: '#a6da95',
|
||||||
|
},
|
||||||
jlptColors: {
|
jlptColors: {
|
||||||
N1: '#ed8796',
|
N1: '#ed8796',
|
||||||
N2: '#f5a97f',
|
N2: '#f5a97f',
|
||||||
|
|||||||
@@ -295,6 +295,20 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
|
defaultValue: defaultConfig.ankiConnect.knownWords.highlightEnabled,
|
||||||
description: 'Enable fast local highlighting for words already known in Anki.',
|
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',
|
path: 'ankiConnect.knownWords.refreshMinutes',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
@@ -404,8 +418,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
path: 'tsukihime.apiBaseUrl',
|
path: 'tsukihime.apiBaseUrl',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
||||||
description:
|
description: 'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||||
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tsukihime.maxSearchResults',
|
path: 'tsukihime.maxSearchResults',
|
||||||
|
|||||||
@@ -109,6 +109,34 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
|
defaultValue: defaultConfig.subtitleStyle.nPlusOneColor,
|
||||||
description: 'Color used for the single N+1 target token subtitle highlight.',
|
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',
|
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -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',
|
id: 'subtitle.annotation.nPlusOne',
|
||||||
path: 'ankiConnect.nPlusOne.enabled',
|
path: 'ankiConnect.nPlusOne.enabled',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
|
import { DEFAULT_CONFIG, deepCloneConfig } from '../definitions';
|
||||||
import { createWarningCollector } from '../warnings';
|
import { createWarningCollector } from '../warnings';
|
||||||
import { applyAnkiConnectResolution } from './anki-connect';
|
import { applyAnkiConnectResolution } from './anki-connect';
|
||||||
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
|
|
||||||
function makeContext(ankiConnect: unknown): {
|
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', () => {
|
test('normalizes ankiConnect tags by trimming and deduping', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
tags: [' SubMiner ', 'Mining', 'SubMiner', ' Mining '],
|
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', () => {
|
test('maps legacy ankiConnect.wordField to modern ankiConnect.fields.word', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
wordField: 'TargetWordLegacy',
|
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.port'));
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
|
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, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,952 +1,25 @@
|
|||||||
import { DEFAULT_CONFIG } from '../definitions';
|
|
||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { isNotificationType, type NotificationType } from '../../types/notification';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { asBoolean, asColor, asNumber, asString, isObject } from './shared';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
function asNotificationType(value: unknown): NotificationType | undefined {
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
return isNotificationType(value) ? value : undefined;
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
}
|
import { isObject } from './shared';
|
||||||
|
|
||||||
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||||
if (!isObject(context.src.ankiConnect)) {
|
if (!isObject(context.src.ankiConnect)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ac = context.src.ankiConnect;
|
const ankiConnect = context.src.ankiConnect;
|
||||||
const behavior = isObject(ac.behavior) ? (ac.behavior as Record<string, unknown>) : {};
|
const behavior = isObject(ankiConnect.behavior) ? ankiConnect.behavior : {};
|
||||||
const fields = isObject(ac.fields) ? (ac.fields as Record<string, unknown>) : {};
|
const fields = isObject(ankiConnect.fields) ? ankiConnect.fields : {};
|
||||||
const media = isObject(ac.media) ? (ac.media as Record<string, unknown>) : {};
|
const media = isObject(ankiConnect.media) ? ankiConnect.media : {};
|
||||||
const metadata = isObject(ac.metadata) ? (ac.metadata as Record<string, unknown>) : {};
|
const metadata = isObject(ankiConnect.metadata) ? ankiConnect.metadata : {};
|
||||||
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 {
|
initializeAnkiConnectResolution(context, ankiConnect);
|
||||||
knownWords: _knownWordsConfigFromAnkiConnect,
|
applyAnkiModernResolution(context, ankiConnect, behavior, media);
|
||||||
nPlusOne: _nPlusOneConfigFromAnkiConnect,
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
ai: _ankiAiConfig,
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
...ankiConnectWithoutKnownWordsOrNPlusOne
|
applyAnkiKikuResolution(context);
|
||||||
} = 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,32 @@ test('settings registry groups annotation display fields by config group', () =>
|
|||||||
assert.equal(field('subtitleStyle.jlptColors.N1').control, 'color');
|
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', () => {
|
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').category, 'behavior');
|
||||||
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
assert.equal(field('ankiConnect.knownWords.addMinedWordsImmediately').section, 'Known Words');
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'ankiConnect.proxy.enabled',
|
'ankiConnect.proxy.enabled',
|
||||||
'ankiConnect.isLapis.enabled',
|
'ankiConnect.isLapis.enabled',
|
||||||
'ankiConnect.isKiku.enabled',
|
'ankiConnect.isKiku.enabled',
|
||||||
|
'subtitleStyle.knownWordColor',
|
||||||
|
'ankiConnect.knownWords.matureThresholdDays',
|
||||||
|
'subtitleStyle.knownWordMaturityColors.new',
|
||||||
|
'subtitleStyle.knownWordMaturityColors.learning',
|
||||||
|
'subtitleStyle.knownWordMaturityColors.young',
|
||||||
|
'subtitleStyle.knownWordMaturityColors.mature',
|
||||||
'subtitleStyle.fontColor',
|
'subtitleStyle.fontColor',
|
||||||
'subtitleStyle.backgroundColor',
|
'subtitleStyle.backgroundColor',
|
||||||
'subtitleStyle.hoverTokenColor',
|
'subtitleStyle.hoverTokenColor',
|
||||||
@@ -353,6 +359,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path.startsWith('ankiConnect.nPlusOne.') ||
|
path.startsWith('ankiConnect.nPlusOne.') ||
|
||||||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
path.startsWith('subtitleStyle.frequencyDictionary.') ||
|
||||||
path.startsWith('subtitleStyle.jlptColors.') ||
|
path.startsWith('subtitleStyle.jlptColors.') ||
|
||||||
|
path.startsWith('subtitleStyle.knownWordMaturityColors.') ||
|
||||||
path === 'subtitleStyle.enableJlpt' ||
|
path === 'subtitleStyle.enableJlpt' ||
|
||||||
path === 'subtitleStyle.knownWordColor' ||
|
path === 'subtitleStyle.knownWordColor' ||
|
||||||
path === 'subtitleStyle.nPlusOneColor' ||
|
path === 'subtitleStyle.nPlusOneColor' ||
|
||||||
@@ -516,6 +523,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
|
|||||||
return 'key-code';
|
return 'key-code';
|
||||||
}
|
}
|
||||||
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
|
||||||
|
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
|
||||||
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
|
||||||
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
|
||||||
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
|
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 {
|
function subsectionForPath(path: string): string | undefined {
|
||||||
if (path === 'ankiConnect.knownWords.highlightEnabled') return 'Known Words';
|
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 === 'ankiConnect.nPlusOne.enabled') return 'N+1';
|
||||||
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
if (path === 'subtitleStyle.knownWordColor') return 'Known Words';
|
||||||
if (path === 'subtitleStyle.nPlusOneColor') return 'N+1';
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -900,9 +900,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.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 () => {
|
it('GET /api/stats/vocabulary returns POS fields', async () => {
|
||||||
@@ -1001,6 +1003,36 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 404);
|
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 () => {
|
it('GET /api/stats/anime/:animeId/cover returns cover art', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anime/1/cover');
|
const res = await app.request('/api/stats/anime/1/cover');
|
||||||
@@ -1348,6 +1380,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 () => {
|
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
@@ -2155,7 +2267,7 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
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);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2279,8 +2391,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2335,8 +2447,8 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
|
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
|
||||||
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+\.avif">$/);
|
assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
|
||||||
assert.equal(fields.ExpressionAudio, undefined);
|
assert.equal(fields.ExpressionAudio, undefined);
|
||||||
assert.equal(fields.SelectionText, undefined);
|
assert.equal(fields.SelectionText, undefined);
|
||||||
});
|
});
|
||||||
@@ -2492,7 +2604,7 @@ Aligned English subtitle
|
|||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
assert.match(
|
assert.match(
|
||||||
updateRequest?.params?.note?.fields?.Voice ?? '',
|
updateRequest?.params?.note?.fields?.Voice ?? '',
|
||||||
/^\[sound:subminer_audio_\d+\.mp3\]$/,
|
/^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ notesInfoFields: null },
|
{ notesInfoFields: null },
|
||||||
@@ -2580,7 +2692,11 @@ Aligned English subtitle
|
|||||||
|
|
||||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
|
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);
|
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2670,6 +2786,26 @@ Aligned English subtitle
|
|||||||
assert.deepEqual(await res.json(), { ok: true });
|
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 () => {
|
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||||
const app = createStatsApp(createMockTracker());
|
const app = createStatsApp(createMockTracker());
|
||||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||||
@@ -2694,8 +2830,10 @@ Aligned English subtitle
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let acquireCalls = 0;
|
let acquireCalls = 0;
|
||||||
let recordCalls = 0;
|
let recordCalls = 0;
|
||||||
globalThis.fetch = (async () =>
|
let requestSignal: AbortSignal | null | undefined;
|
||||||
new Response(
|
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
requestSignal = init?.signal;
|
||||||
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
@@ -2707,7 +2845,8 @@ Aligned English subtitle
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
|
||||||
},
|
},
|
||||||
)) as typeof fetch;
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const app = createStatsApp(createMockTracker(), {
|
const app = createStatsApp(createMockTracker(), {
|
||||||
@@ -2725,6 +2864,7 @@ Aligned English subtitle
|
|||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.equal(acquireCalls, 1);
|
assert.equal(acquireCalls, 1);
|
||||||
assert.equal(recordCalls, 1);
|
assert.equal(recordCalls, 1);
|
||||||
|
assert.ok(requestSignal instanceof AbortSignal);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
@@ -2923,6 +3063,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 () => {
|
it('fetches and serves missing cover art on demand', async () => {
|
||||||
let ensureCalls = 0;
|
let ensureCalls = 0;
|
||||||
let hasCover = false;
|
let hasCover = false;
|
||||||
|
|||||||
@@ -302,9 +302,7 @@ export function registerAnkiJimakuIpcHandlers(
|
|||||||
await deps.onDownloadedSecondarySubtitle(result.path);
|
await deps.onDownloadedSecondarySubtitle(result.path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`);
|
||||||
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+20
-1436
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,258 @@
|
|||||||
|
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 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 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 raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
||||||
|
version?: number;
|
||||||
|
words?: string[];
|
||||||
|
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
|
||||||
|
};
|
||||||
|
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||||
|
return new Set(raw.words);
|
||||||
|
}
|
||||||
|
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
|
||||||
|
const words = new Set<string>();
|
||||||
|
for (const entries of Object.values(raw.notes)) {
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -48,7 +48,10 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
|
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
|
||||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
|
assert.equal(
|
||||||
|
parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
|
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
|
|
||||||
for (let i = 0; i < rest.length; i += 1) {
|
for (let i = 0; i < rest.length; i += 1) {
|
||||||
const token = rest[i]!;
|
const token = rest[i]!;
|
||||||
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
|
const assignValue = valueFlags.get(
|
||||||
|
token.includes('=') ? token.slice(0, token.indexOf('=')) : token,
|
||||||
|
);
|
||||||
if (assignValue) {
|
if (assignValue) {
|
||||||
if (token.includes('=')) {
|
if (token.includes('=')) {
|
||||||
assignValue(token.slice(token.indexOf('=') + 1));
|
assignValue(token.slice(token.indexOf('=') + 1));
|
||||||
@@ -109,7 +111,10 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
|||||||
Boolean(removeTemp),
|
Boolean(removeTemp),
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
if (modes === 0) {
|
if (modes === 0) {
|
||||||
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: 'Sync requires a host, --snapshot <file>, or --merge <file>.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (modes > 1) {
|
if (modes > 1) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export function resolveImmersionDbPath(): string {
|
|||||||
// no config or unreadable config → default location
|
// no config or unreadable config → default location
|
||||||
}
|
}
|
||||||
if (configured) {
|
if (configured) {
|
||||||
return configured.startsWith('~')
|
return configured.startsWith('~') ? path.join(os.homedir(), configured.slice(1)) : configured;
|
||||||
? path.join(os.homedir(), configured.slice(1))
|
|
||||||
: configured;
|
|
||||||
}
|
}
|
||||||
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
|
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,11 +251,7 @@ export function mergeMediaMetadata(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeExcludedWords(
|
export function mergeExcludedWords(local: SyncDb, remote: SyncDb, summary: SyncMergeSummary): void {
|
||||||
local: SyncDb,
|
|
||||||
remote: SyncDb,
|
|
||||||
summary: SyncMergeSummary,
|
|
||||||
): void {
|
|
||||||
if (
|
if (
|
||||||
!tableExists(remote, 'imm_stats_excluded_words') ||
|
!tableExists(remote, 'imm_stats_excluded_words') ||
|
||||||
!tableExists(local, 'imm_stats_excluded_words')
|
!tableExists(local, 'imm_stats_excluded_words')
|
||||||
|
|||||||
@@ -147,7 +147,9 @@ export function refreshRollupsForNewSessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stampMs = nowDbTimestamp();
|
const stampMs = nowDbTimestamp();
|
||||||
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
|
const deleteDaily = local.query(
|
||||||
|
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||||
|
);
|
||||||
const deleteMonthly = local.query(
|
const deleteMonthly = local.query(
|
||||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ function applyMergedSessionLifetime(
|
|||||||
.get(videoId, sessionId),
|
.get(videoId, sessionId),
|
||||||
);
|
);
|
||||||
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
|
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
|
||||||
const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
|
const isFirstCompletedSessionForVideoRun =
|
||||||
|
watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
|
||||||
|
|
||||||
const hasOtherSessionOnDay = Boolean(
|
const hasOtherSessionOnDay = Boolean(
|
||||||
local
|
local
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import {
|
|||||||
} from './merge-catalog';
|
} from './merge-catalog';
|
||||||
import { mergeSessions } from './merge-sessions';
|
import { mergeSessions } from './merge-sessions';
|
||||||
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
|
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
|
||||||
import {
|
import { assertMergeableSchema, createEmptyMergeSummary, type SyncMergeSummary } from './shared';
|
||||||
assertMergeableSchema,
|
|
||||||
createEmptyMergeSummary,
|
|
||||||
type SyncMergeSummary,
|
|
||||||
} from './shared';
|
|
||||||
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
|
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
|
||||||
|
|
||||||
export type { SyncMergeSummary } from './shared';
|
export type { SyncMergeSummary } from './shared';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Token,
|
Token,
|
||||||
FrequencyDictionaryLookup,
|
FrequencyDictionaryLookup,
|
||||||
JlptLevel,
|
JlptLevel,
|
||||||
|
KnownWordMaturityTier,
|
||||||
PartOfSpeech,
|
PartOfSpeech,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,12 @@ export type KnownWordLookupFn = (
|
|||||||
options?: { allowReadingOnlyMatch?: boolean },
|
options?: { allowReadingOnlyMatch?: boolean },
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
|
||||||
|
export type KnownWordTierLookupFn = (
|
||||||
|
text: string,
|
||||||
|
reading?: string,
|
||||||
|
options?: { allowReadingOnlyMatch?: boolean },
|
||||||
|
) => KnownWordMaturityTier | null;
|
||||||
|
|
||||||
export interface TokenizerServiceDeps {
|
export interface TokenizerServiceDeps {
|
||||||
getYomitanExt: () => Extension | null;
|
getYomitanExt: () => Extension | null;
|
||||||
getYomitanSession?: () => Session | null;
|
getYomitanSession?: () => Session | null;
|
||||||
@@ -53,6 +60,7 @@ export interface TokenizerServiceDeps {
|
|||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: KnownWordLookupFn;
|
isKnownWord: KnownWordLookupFn;
|
||||||
|
getKnownWordTier?: KnownWordTierLookupFn;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
getKnownWordsEnabled?: () => boolean;
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
@@ -88,6 +96,7 @@ export interface TokenizerDepsRuntimeOptions {
|
|||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
isKnownWord: KnownWordLookupFn;
|
isKnownWord: KnownWordLookupFn;
|
||||||
|
getKnownWordTier?: KnownWordTierLookupFn;
|
||||||
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
getKnownWordMatchMode: () => NPlusOneMatchMode;
|
||||||
getKnownWordsEnabled?: () => boolean;
|
getKnownWordsEnabled?: () => boolean;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
@@ -204,6 +213,11 @@ async function applyAnnotationStage(
|
|||||||
tokens,
|
tokens,
|
||||||
{
|
{
|
||||||
isKnownWord: getKnownWordLookup(deps, options),
|
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(),
|
knownWordMatchMode: deps.getKnownWordMatchMode(),
|
||||||
getJlptLevel: deps.getJlptLevel,
|
getJlptLevel: deps.getJlptLevel,
|
||||||
},
|
},
|
||||||
@@ -242,6 +256,7 @@ export function createTokenizerDepsRuntime(
|
|||||||
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
getYomitanParserInitPromise: options.getYomitanParserInitPromise,
|
||||||
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
setYomitanParserInitPromise: options.setYomitanParserInitPromise,
|
||||||
isKnownWord: options.isKnownWord,
|
isKnownWord: options.isKnownWord,
|
||||||
|
getKnownWordTier: options.getKnownWordTier,
|
||||||
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
getKnownWordMatchMode: options.getKnownWordMatchMode,
|
||||||
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
getKnownWordsEnabled: options.getKnownWordsEnabled,
|
||||||
getJlptLevel: options.getJlptLevel,
|
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,
|
DEFAULT_ANNOTATION_POS2_EXCLUSION_CONFIG,
|
||||||
resolveAnnotationPos2ExclusionSet,
|
resolveAnnotationPos2ExclusionSet,
|
||||||
} from '../../../token-pos2-exclusions';
|
} 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 { shouldIgnoreJlptByTerm, shouldIgnoreJlptForMecabPos1 } from '../jlpt-token-filter';
|
||||||
import {
|
import {
|
||||||
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
|
shouldExcludeTokenFromSubtitleAnnotations as sharedShouldExcludeTokenFromSubtitleAnnotations,
|
||||||
@@ -36,6 +42,11 @@ export interface AnnotationStageDeps {
|
|||||||
reading?: string,
|
reading?: string,
|
||||||
options?: { allowReadingOnlyMatch?: boolean },
|
options?: { allowReadingOnlyMatch?: boolean },
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
getKnownWordTier?: (
|
||||||
|
text: string,
|
||||||
|
reading?: string,
|
||||||
|
options?: { allowReadingOnlyMatch?: boolean },
|
||||||
|
) => KnownWordMaturityTier | null;
|
||||||
knownWordMatchMode: NPlusOneMatchMode;
|
knownWordMatchMode: NPlusOneMatchMode;
|
||||||
getJlptLevel: (text: string) => JlptLevel | null;
|
getJlptLevel: (text: string) => JlptLevel | null;
|
||||||
}
|
}
|
||||||
@@ -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(
|
function filterTokenFrequencyRank(
|
||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
pos1Exclusions: ReadonlySet<string>,
|
pos1Exclusions: ReadonlySet<string>,
|
||||||
@@ -677,6 +712,11 @@ export function annotateTokens(
|
|||||||
: false;
|
: false;
|
||||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||||
|
|
||||||
|
const knownMaturity =
|
||||||
|
knownWordsEnabled && isKnownForMatching && deps.getKnownWordTier
|
||||||
|
? computeTokenKnownMaturity(token, deps.getKnownWordTier, deps.knownWordMatchMode)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||||
|
|
||||||
// A confirmed character-name match must survive the POS noise filter:
|
// A confirmed character-name match must survive the POS noise filter:
|
||||||
@@ -696,6 +736,7 @@ export function annotateTokens(
|
|||||||
return {
|
return {
|
||||||
...strippedToken,
|
...strippedToken,
|
||||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||||
|
knownMaturity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +753,7 @@ export function annotateTokens(
|
|||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||||
|
knownMaturity,
|
||||||
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
isNPlusOneTarget: nPlusOneEnabled && !prioritizedNameMatch ? token.isNPlusOneTarget : false,
|
||||||
frequencyRank,
|
frequencyRank,
|
||||||
jlptLevel,
|
jlptLevel,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DEFAULT_CONFIG } from './config/definitions';
|
|||||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||||
import {
|
import {
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
exitBackgroundBootstrap,
|
||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
@@ -21,10 +22,17 @@ import {
|
|||||||
shouldHandleStatsDaemonCommandAtEntry,
|
shouldHandleStatsDaemonCommandAtEntry,
|
||||||
hasTransportedStartupArgs,
|
hasTransportedStartupArgs,
|
||||||
shouldForwardStartupArgvViaAppControl,
|
shouldForwardStartupArgvViaAppControl,
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
resolveLinuxPasswordStoreValue,
|
resolveLinuxPasswordStoreValue,
|
||||||
} from './main-entry-runtime';
|
} 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', () => {
|
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
try {
|
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', () => {
|
test('transported AppImage visibility commands forward through app control', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type EarlyAppLike = {
|
|||||||
setPath: (name: 'userData', value: string) => void;
|
setPath: (name: 'userData', value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BackgroundBootstrapAppLike = {
|
||||||
|
exit: (code: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
type CommandLineLike = {
|
type CommandLineLike = {
|
||||||
appendSwitch: (name: string, value?: string) => void;
|
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 {
|
function consumesLaunchMpvValue(token: string): boolean {
|
||||||
return (
|
return (
|
||||||
token.startsWith('--') &&
|
token.startsWith('--') &&
|
||||||
@@ -241,6 +261,10 @@ export function shouldDetachBackgroundLaunch(argv: string[], env: NodeJS.Process
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exitBackgroundBootstrap(app: BackgroundBootstrapAppLike): void {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||||
const args = parseCliArgs(argv);
|
const args = parseCliArgs(argv);
|
||||||
|
|||||||
+5
-1
@@ -3,7 +3,9 @@ import { spawn } from 'node:child_process';
|
|||||||
import { app, dialog, shell } from 'electron';
|
import { app, dialog, shell } from 'electron';
|
||||||
import { printHelp } from './cli/help';
|
import { printHelp } from './cli/help';
|
||||||
import {
|
import {
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
configureEarlyAppPaths,
|
configureEarlyAppPaths,
|
||||||
|
exitBackgroundBootstrap,
|
||||||
normalizeLaunchMpvExtraArgs,
|
normalizeLaunchMpvExtraArgs,
|
||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
@@ -174,6 +176,7 @@ function createWindowsRuntimePluginPolicy() {
|
|||||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||||
|
applyBackgroundBootstrapCommandLineSwitches(app.commandLine, process.argv, process.env);
|
||||||
const userDataPath = configureEarlyAppPaths(app);
|
const userDataPath = configureEarlyAppPaths(app);
|
||||||
const reportFatalError = createFatalErrorReporter({
|
const reportFatalError = createFatalErrorReporter({
|
||||||
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
||||||
@@ -308,7 +311,8 @@ async function runEntryProcess(): Promise<void> {
|
|||||||
env: sanitizeBackgroundEnv(process.env),
|
env: sanitizeBackgroundEnv(process.env),
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
process.exit(0);
|
// Let Electron stop bootstrap Chromium children before its AppImage mount is released.
|
||||||
|
exitBackgroundBootstrap(app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+399
-659
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,13 @@ function runKeepaliveScript(
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile(
|
execFile(
|
||||||
'/bin/sh',
|
'/bin/sh',
|
||||||
['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath, ...extraArgs],
|
[
|
||||||
|
'-c',
|
||||||
|
APPIMAGE_MOUNT_KEEPALIVE_SCRIPT,
|
||||||
|
APPIMAGE_MOUNT_KEEPALIVE_LABEL,
|
||||||
|
appImagePath,
|
||||||
|
...extraArgs,
|
||||||
|
],
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error && typeof error.code !== 'number') {
|
if (error && typeof error.code !== 'number') {
|
||||||
@@ -69,113 +75,107 @@ function writeExecutable(filePath: string, content: string): void {
|
|||||||
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
||||||
}
|
}
|
||||||
|
|
||||||
test(
|
const linuxTest = process.platform === 'linux' ? test : test.skip;
|
||||||
'keepalive script releases the mount only after straggler processes exit',
|
|
||||||
{ skip: process.platform !== 'linux' },
|
|
||||||
async () => {
|
|
||||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
|
||||||
const resultsDir = path.join(workDir, 'results');
|
|
||||||
fs.mkdirSync(resultsDir);
|
|
||||||
const mountDir = path.join(workDir, 'fake-mount');
|
|
||||||
fs.mkdirSync(mountDir);
|
|
||||||
|
|
||||||
// AppRun leaves behind a straggler that keeps executing *from the mount*
|
linuxTest('keepalive script releases the mount only after straggler processes exit', async () => {
|
||||||
// after AppRun itself exits — mimicking Chromium utility children.
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||||
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
|
const resultsDir = path.join(workDir, 'results');
|
||||||
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
|
fs.mkdirSync(resultsDir);
|
||||||
writeExecutable(
|
const mountDir = path.join(workDir, 'fake-mount');
|
||||||
path.join(mountDir, 'AppRun'),
|
fs.mkdirSync(mountDir);
|
||||||
[
|
|
||||||
'#!/bin/sh',
|
// AppRun leaves behind a straggler that keeps executing *from the mount*
|
||||||
`"${mountDir}/straggler" 1 &`,
|
// after AppRun itself exits — mimicking Chromium utility children.
|
||||||
`date +%s%N > "${resultsDir}/apprun-exited"`,
|
fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler'));
|
||||||
'exit 42',
|
fs.chmodSync(path.join(mountDir, 'straggler'), 0o755);
|
||||||
].join('\n'),
|
writeExecutable(
|
||||||
|
path.join(mountDir, 'AppRun'),
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`"${mountDir}/straggler" 1 &`,
|
||||||
|
`date +%s%N > "${resultsDir}/apprun-exited"`,
|
||||||
|
'exit 42',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||||
|
writeExecutable(
|
||||||
|
fakeAppImage,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||||
|
` echo "${mountDir}"`,
|
||||||
|
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
||||||
|
' while :; do sleep 0.05; done',
|
||||||
|
'fi',
|
||||||
|
`date +%s%N > "${resultsDir}/direct-run"`,
|
||||||
|
'exit 0',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status } = await runKeepaliveScript(fakeAppImage);
|
||||||
|
|
||||||
|
assert.equal(status, 42, 'exit code of AppRun must be propagated');
|
||||||
|
assert.ok(
|
||||||
|
!fs.existsSync(path.join(resultsDir, 'direct-run')),
|
||||||
|
'must not fall back to direct AppImage run when mount succeeds',
|
||||||
);
|
);
|
||||||
|
// The script does not wait for the holder to finish handling SIGTERM
|
||||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
// (the real runtime unmounts on its own after the signal), so poll.
|
||||||
writeExecutable(
|
const releasedMarker = path.join(resultsDir, 'holder-released');
|
||||||
fakeAppImage,
|
const pollDeadline = Date.now() + 2000;
|
||||||
[
|
let holderReleased: number | null = null;
|
||||||
'#!/bin/sh',
|
while (holderReleased === null && Date.now() < pollDeadline) {
|
||||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
if (fs.existsSync(releasedMarker)) {
|
||||||
` echo "${mountDir}"`,
|
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
|
||||||
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
|
||||||
' while :; do sleep 0.05; done',
|
|
||||||
'fi',
|
|
||||||
`date +%s%N > "${resultsDir}/direct-run"`,
|
|
||||||
'exit 0',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { status } = await runKeepaliveScript(fakeAppImage);
|
|
||||||
|
|
||||||
assert.equal(status, 42, 'exit code of AppRun must be propagated');
|
|
||||||
assert.ok(
|
|
||||||
!fs.existsSync(path.join(resultsDir, 'direct-run')),
|
|
||||||
'must not fall back to direct AppImage run when mount succeeds',
|
|
||||||
);
|
|
||||||
// The script does not wait for the holder to finish handling SIGTERM
|
|
||||||
// (the real runtime unmounts on its own after the signal), so poll.
|
|
||||||
const releasedMarker = path.join(resultsDir, 'holder-released');
|
|
||||||
const pollDeadline = Date.now() + 2000;
|
|
||||||
let holderReleased: number | null = null;
|
|
||||||
while (holderReleased === null && Date.now() < pollDeadline) {
|
|
||||||
if (fs.existsSync(releasedMarker)) {
|
|
||||||
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
|
|
||||||
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
|
|
||||||
}
|
|
||||||
if (holderReleased !== null) break;
|
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
|
||||||
}
|
}
|
||||||
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
|
if (holderReleased !== null) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
const appRunExited = Number(
|
|
||||||
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
|
||||||
);
|
|
||||||
const drainNs = holderReleased - appRunExited;
|
|
||||||
assert.ok(
|
|
||||||
drainNs >= 0.8e9,
|
|
||||||
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
},
|
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
const appRunExited = Number(
|
||||||
'keepalive script falls back to direct run when --appimage-mount fails',
|
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
||||||
{ skip: process.platform !== 'linux' },
|
|
||||||
async () => {
|
|
||||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
|
||||||
const resultsDir = path.join(workDir, 'results');
|
|
||||||
fs.mkdirSync(resultsDir);
|
|
||||||
|
|
||||||
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
|
||||||
writeExecutable(
|
|
||||||
fakeAppImage,
|
|
||||||
[
|
|
||||||
'#!/bin/sh',
|
|
||||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
|
||||||
' exit 1',
|
|
||||||
'fi',
|
|
||||||
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
|
|
||||||
'exit 7',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
);
|
||||||
|
const drainNs = holderReleased - appRunExited;
|
||||||
|
assert.ok(
|
||||||
|
drainNs >= 0.8e9,
|
||||||
|
`holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
linuxTest('keepalive script falls back to direct run when --appimage-mount fails', async () => {
|
||||||
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-'));
|
||||||
assert.equal(status, 7, 'direct-run exit code must be propagated');
|
const resultsDir = path.join(workDir, 'results');
|
||||||
assert.equal(
|
fs.mkdirSync(resultsDir);
|
||||||
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
|
|
||||||
'--start\n--background\n',
|
const fakeAppImage = path.join(workDir, 'Fake.AppImage');
|
||||||
'launch args must be forwarded to the direct run',
|
writeExecutable(
|
||||||
);
|
fakeAppImage,
|
||||||
} finally {
|
[
|
||||||
fs.rmSync(workDir, { recursive: true, force: true });
|
'#!/bin/sh',
|
||||||
}
|
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||||
},
|
' exit 1',
|
||||||
);
|
'fi',
|
||||||
|
`printf '%s\\n' "$@" > "${resultsDir}/direct-run"`,
|
||||||
|
'exit 7',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']);
|
||||||
|
assert.equal(status, 7, 'direct-run exit code must be propagated');
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'),
|
||||||
|
'--start\n--background\n',
|
||||||
|
'launch args must be forwarded to the direct run',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
test('manual selection store resolves legacy unscoped override keys', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
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 () => {
|
test('manual selection store keeps overrides separate for different season directories', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
|
|||||||
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
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: {
|
export function buildCharacterDictionarySeriesKey(input: {
|
||||||
mediaPath: string | null;
|
mediaPath: string | null;
|
||||||
mediaTitle: string | null;
|
mediaTitle: string | null;
|
||||||
@@ -135,7 +140,23 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
|||||||
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||||
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||||
const overrides = readOverrides(filePath);
|
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);
|
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
@@ -146,8 +167,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error('Invalid character dictionary manual selection.');
|
throw new Error('Invalid character dictionary manual selection.');
|
||||||
}
|
}
|
||||||
const remaining = readOverrides(filePath).filter(
|
const directoryScope = getDirectoryScope(normalized.seriesKey);
|
||||||
(entry) => entry.seriesKey !== normalized.seriesKey,
|
const remaining = readOverrides(filePath).filter((entry) =>
|
||||||
|
directoryScope
|
||||||
|
? getDirectoryScope(entry.seriesKey) !== directoryScope
|
||||||
|
: entry.seriesKey !== normalized.seriesKey,
|
||||||
);
|
);
|
||||||
writeOverrides(filePath, [...remaining, normalized]);
|
writeOverrides(filePath, [...remaining, normalized]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
test('main process call sites bypass zero-logic pass-through wrappers', () => {
|
||||||
|
const sourcePath = path.join(process.cwd(), 'src/main.ts');
|
||||||
|
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
sourcePath,
|
||||||
|
source,
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
ts.ScriptKind.TS,
|
||||||
|
);
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const statement of sourceFile.statements) {
|
||||||
|
if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) {
|
||||||
|
if (isRuntimePassthrough(statement, statement.body)) {
|
||||||
|
offenders.push(statement.name.text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ts.isVariableStatement(statement)) continue;
|
||||||
|
for (const declaration of statement.declarationList.declarations) {
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(declaration.name) &&
|
||||||
|
declaration.initializer &&
|
||||||
|
(ts.isArrowFunction(declaration.initializer) ||
|
||||||
|
ts.isFunctionExpression(declaration.initializer)) &&
|
||||||
|
isRuntimePassthrough(declaration.initializer, declaration.initializer.body)
|
||||||
|
) {
|
||||||
|
offenders.push(declaration.name.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(offenders, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isRuntimePassthrough(
|
||||||
|
callable: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
|
||||||
|
body: ts.ConciseBody,
|
||||||
|
): boolean {
|
||||||
|
let expression: ts.Expression | undefined;
|
||||||
|
if (!ts.isBlock(body)) {
|
||||||
|
expression = body;
|
||||||
|
} else {
|
||||||
|
if (body.statements.length !== 1) return false;
|
||||||
|
const statement = body.statements[0]!;
|
||||||
|
if (ts.isExpressionStatement(statement)) expression = statement.expression;
|
||||||
|
if (ts.isReturnStatement(statement)) expression = statement.expression;
|
||||||
|
}
|
||||||
|
if (!expression) return false;
|
||||||
|
if (ts.isAwaitExpression(expression) || ts.isVoidExpression(expression)) {
|
||||||
|
expression = expression.expression;
|
||||||
|
}
|
||||||
|
if (!ts.isCallExpression(expression)) return false;
|
||||||
|
|
||||||
|
const root = getCalleeRoot(expression.expression);
|
||||||
|
if (!root) return false;
|
||||||
|
if (callable.parameters.length !== expression.arguments.length) return false;
|
||||||
|
|
||||||
|
return callable.parameters.every((parameter, index) => {
|
||||||
|
if (!ts.isIdentifier(parameter.name)) return false;
|
||||||
|
const argument = expression.arguments[index];
|
||||||
|
if (!argument) return false;
|
||||||
|
if (parameter.dotDotDotToken) {
|
||||||
|
if (!ts.isSpreadElement(argument)) return false;
|
||||||
|
const spreadExpression = unwrapTransparentExpression(argument.expression);
|
||||||
|
return ts.isIdentifier(spreadExpression) && spreadExpression.text === parameter.name.text;
|
||||||
|
}
|
||||||
|
const argumentExpression = unwrapTransparentExpression(argument);
|
||||||
|
return ts.isIdentifier(argumentExpression) && argumentExpression.text === parameter.name.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapTransparentExpression(expression: ts.Expression): ts.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
ts.isParenthesizedExpression(current) ||
|
||||||
|
ts.isAsExpression(current) ||
|
||||||
|
ts.isNonNullExpression(current)
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalleeRoot(expression: ts.LeftHandSideExpression): string {
|
||||||
|
let current: ts.Expression = expression;
|
||||||
|
while (ts.isPropertyAccessExpression(current) || ts.isCallExpression(current)) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return ts.isIdentifier(current) ? current.text : '';
|
||||||
|
}
|
||||||
@@ -446,7 +446,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
|||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
|
assert.match(
|
||||||
|
actionBlock,
|
||||||
|
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
|
||||||
|
);
|
||||||
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
|
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
|
||||||
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
|
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
|
||||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||||
@@ -556,11 +559,60 @@ test('YouTube media cache lifecycle routes through configured status notificatio
|
|||||||
);
|
);
|
||||||
assert.match(cacheBlock, /variant:\s*'success'/);
|
assert.match(cacheBlock, /variant:\s*'success'/);
|
||||||
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
assert.match(cacheBlock, /notifyNoQueued:\s*false/);
|
||||||
assert.match(startCacheBlock, /mode:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.mode/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
startCacheBlock,
|
startCacheBlock,
|
||||||
/maxHeight:\s*getResolvedConfig\(\)\.youtube\.mediaCache\.maxHeight/,
|
/const mediaCacheConfig = configService\.getConfig\(\)\.youtube\.mediaCache;/,
|
||||||
);
|
);
|
||||||
|
assert.match(startCacheBlock, /mode:\s*mediaCacheConfig\.mode/);
|
||||||
|
assert.match(startCacheBlock, /maxHeight:\s*mediaCacheConfig\.maxHeight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle broadcasts share one frequency options snapshot per emitted payload', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const emitBlock = source.match(
|
||||||
|
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const frequencyOptionsSnapshot = emitBlock?.match(
|
||||||
|
/const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;(?<body>[\s\S]*?)\n \};/,
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
|
assert.ok(emitBlock);
|
||||||
|
assert.ok(frequencyOptionsSnapshot);
|
||||||
|
assert.match(frequencyOptionsSnapshot, /const frequencyOptions = \{/);
|
||||||
|
assert.match(frequencyOptionsSnapshot, /enabled:\s*frequencyDictionary\.enabled/);
|
||||||
|
assert.match(frequencyOptionsSnapshot, /topX:\s*frequencyDictionary\.topX/);
|
||||||
|
assert.match(frequencyOptionsSnapshot, /mode:\s*frequencyDictionary\.mode/);
|
||||||
|
assert.equal((frequencyOptionsSnapshot.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||||
|
assert.match(emitBlock, /subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/);
|
||||||
|
assert.match(
|
||||||
|
emitBlock,
|
||||||
|
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\);/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const subtitleBlock = source.match(
|
||||||
|
/startSubtitleWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startAnnotationWebsocket:/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const annotationBlock = source.match(
|
||||||
|
/startAnnotationWebsocket:\s*\(port: number\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n startTexthooker:/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const subtitleFrequencyOptionsCallback = subtitleBlock?.match(
|
||||||
|
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const annotationFrequencyOptionsCallback = annotationBlock?.match(
|
||||||
|
/(?<body>\(\) => \{\s+const frequencyDictionary = configService\.getConfig\(\)\.subtitleStyle\.frequencyDictionary;[\s\S]*?\s+return \{[\s\S]*?\s+\};\s+\})/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(subtitleFrequencyOptionsCallback);
|
||||||
|
assert.ok(annotationFrequencyOptionsCallback);
|
||||||
|
for (const callback of [subtitleFrequencyOptionsCallback, annotationFrequencyOptionsCallback]) {
|
||||||
|
assert.match(callback, /return \{[\s\S]*enabled:\s*frequencyDictionary\.enabled/);
|
||||||
|
assert.match(callback, /topX:\s*frequencyDictionary\.topX/);
|
||||||
|
assert.match(callback, /mode:\s*frequencyDictionary\.mode/);
|
||||||
|
assert.equal((callback.match(/configService\.getConfig\(\)/g) ?? []).length, 1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv connection flushes queued configured OSD notifications', () => {
|
test('mpv connection flushes queued configured OSD notifications', () => {
|
||||||
@@ -587,11 +639,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
|||||||
assert.ok(toggleBlock);
|
assert.ok(toggleBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void autoplaySubtitlePrimingRuntime\.primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -612,7 +664,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
|||||||
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+visibleOverlayInteractionRuntime\.resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+visibleOverlayInteractionRuntime\.startLinuxVisibleOverlayStartupInputGrace\(\);\s+visibleOverlayInteractionRuntime\.restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -663,14 +715,20 @@ test('Linux visible overlay show starts input grace before first measurement', (
|
|||||||
|
|
||||||
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
|
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
|
||||||
assert.ok(block);
|
assert.ok(block);
|
||||||
assert.ok(
|
const resetIndex = block.indexOf(
|
||||||
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
|
'visibleOverlayInteractionRuntime.resetLinuxVisibleOverlayStartupInputPrimer();',
|
||||||
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
|
|
||||||
);
|
);
|
||||||
assert.ok(
|
const graceIndex = block.indexOf(
|
||||||
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
|
'visibleOverlayInteractionRuntime.startLinuxVisibleOverlayStartupInputGrace();',
|
||||||
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
|
|
||||||
);
|
);
|
||||||
|
const primeIndex = block.indexOf(
|
||||||
|
'void autoplaySubtitlePrimingRuntime.primeCurrentSubtitleForVisibleOverlay();',
|
||||||
|
);
|
||||||
|
assert.ok(resetIndex >= 0);
|
||||||
|
assert.ok(graceIndex >= 0);
|
||||||
|
assert.ok(primeIndex >= 0);
|
||||||
|
assert.ok(resetIndex < graceIndex);
|
||||||
|
assert.ok(graceIndex < primeIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
|
|||||||
deps.recordLookup(hit);
|
deps.recordLookup(hit);
|
||||||
return hit;
|
return hit;
|
||||||
},
|
},
|
||||||
|
...(deps.getKnownWordTier
|
||||||
|
? {
|
||||||
|
getKnownWordTier: (text, reading, options) =>
|
||||||
|
deps.getKnownWordTier!(text, reading, options),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
|
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
|
||||||
...(deps.getKnownWordsEnabled
|
...(deps.getKnownWordsEnabled
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -147,10 +147,7 @@ test('runSyncLauncher settles on exit when the terminal event is already parsed'
|
|||||||
onEvent: () => {},
|
onEvent: () => {},
|
||||||
spawn,
|
spawn,
|
||||||
});
|
});
|
||||||
children[0]!.stdout.emit(
|
children[0]!.stdout.emit('data', Buffer.from('{"type":"result","ok":true,"error":null}\n'));
|
||||||
'data',
|
|
||||||
Buffer.from('{"type":"result","ok":true,"error":null}\n'),
|
|
||||||
);
|
|
||||||
children[0]!.emit('exit', 0, null);
|
children[0]!.emit('exit', 0, null);
|
||||||
|
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user