feat: add AniList character dictionary sync
3
.github/workflows/ci.yml
vendored
@@ -57,9 +57,6 @@ jobs:
|
|||||||
- name: Dist smoke suite
|
- name: Dist smoke suite
|
||||||
run: bun run test:smoke:dist
|
run: bun run test:smoke:dist
|
||||||
|
|
||||||
- name: Build docs
|
|
||||||
run: bun run docs:build
|
|
||||||
|
|
||||||
- name: Security audit
|
- name: Security audit
|
||||||
run: bun audit --audit-level high
|
run: bun audit --audit-level high
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
18
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config docs-dev docs docs-preview docs-watch dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
@@ -56,10 +56,6 @@ help:
|
|||||||
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||||
" dev-stop Stop a running local Electron app" \
|
" dev-stop Stop a running local Electron app" \
|
||||||
" docs-dev Run VitePress docs dev server" \
|
|
||||||
" docs-watch Run VitePress docs dev + Backlog browser together" \
|
|
||||||
" docs Build VitePress static docs" \
|
|
||||||
" docs-preview Preview built VitePress docs" \
|
|
||||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||||
" install-plugin Install mpv Lua plugin and plugin config" \
|
" install-plugin Install mpv Lua plugin and plugin config" \
|
||||||
@@ -158,18 +154,6 @@ generate-example-config: ensure-bun
|
|||||||
@bun run build
|
@bun run build
|
||||||
@bun run generate:config-example
|
@bun run generate:config-example
|
||||||
|
|
||||||
docs-dev: ensure-bun
|
|
||||||
@bun run docs:dev
|
|
||||||
|
|
||||||
docs-watch: ensure-bun
|
|
||||||
@bun run docs:watch
|
|
||||||
|
|
||||||
docs: ensure-bun
|
|
||||||
@bun run docs:build
|
|
||||||
|
|
||||||
docs-preview: ensure-bun
|
|
||||||
@bun run docs:preview
|
|
||||||
|
|
||||||
dev-start: ensure-bun
|
dev-start: ensure-bun
|
||||||
@bun run build
|
@bun run build
|
||||||
@bun run electron . --start
|
@bun run electron . --start
|
||||||
|
|||||||
615
bun.lock
@@ -5,12 +5,10 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"mermaid": "^11.12.3",
|
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -21,81 +19,14 @@
|
|||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitepress": "^1.6.4",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
||||||
|
|
||||||
"@algolia/abtesting": ["@algolia/abtesting@1.14.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew=="],
|
|
||||||
|
|
||||||
"@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="],
|
|
||||||
|
|
||||||
"@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": "2.17.3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="],
|
|
||||||
|
|
||||||
"@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": "5.48.1", "algoliasearch": "5.48.1" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="],
|
|
||||||
|
|
||||||
"@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": "5.48.1", "algoliasearch": "5.48.1" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="],
|
|
||||||
|
|
||||||
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw=="],
|
|
||||||
|
|
||||||
"@algolia/client-analytics": ["@algolia/client-analytics@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg=="],
|
|
||||||
|
|
||||||
"@algolia/client-common": ["@algolia/client-common@5.48.1", "", {}, "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw=="],
|
|
||||||
|
|
||||||
"@algolia/client-insights": ["@algolia/client-insights@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ=="],
|
|
||||||
|
|
||||||
"@algolia/client-personalization": ["@algolia/client-personalization@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw=="],
|
|
||||||
|
|
||||||
"@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA=="],
|
|
||||||
|
|
||||||
"@algolia/client-search": ["@algolia/client-search@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q=="],
|
|
||||||
|
|
||||||
"@algolia/ingestion": ["@algolia/ingestion@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w=="],
|
|
||||||
|
|
||||||
"@algolia/monitoring": ["@algolia/monitoring@1.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw=="],
|
|
||||||
|
|
||||||
"@algolia/recommend": ["@algolia/recommend@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw=="],
|
|
||||||
|
|
||||||
"@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw=="],
|
|
||||||
|
|
||||||
"@algolia/requester-fetch": ["@algolia/requester-fetch@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg=="],
|
|
||||||
|
|
||||||
"@algolia/requester-node-http": ["@algolia/requester-node-http@5.48.1", "", { "dependencies": { "@algolia/client-common": "5.48.1" } }, "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg=="],
|
|
||||||
|
|
||||||
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "1.6.0", "tinyexec": "1.0.2" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
|
|
||||||
|
|
||||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
|
||||||
|
|
||||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
|
||||||
|
|
||||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
|
||||||
|
|
||||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
|
||||||
|
|
||||||
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
|
|
||||||
|
|
||||||
"@catppuccin/vitepress": ["@catppuccin/vitepress@0.1.2", "", { "peerDependencies": { "typescript": "5.9.3" } }, "sha512-dqhgo6U6GWbgh3McAgwemUC8Y2Aj48rRcQx/9iuPzBPAgo7NA3yi7ZcR0wolAENMmoOMAHBV+rz/5DfiGxtZLA=="],
|
|
||||||
|
|
||||||
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="],
|
|
||||||
|
|
||||||
"@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="],
|
|
||||||
|
|
||||||
"@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="],
|
|
||||||
|
|
||||||
"@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="],
|
|
||||||
|
|
||||||
"@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="],
|
|
||||||
|
|
||||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "6.12.6", "ajv-keywords": "3.5.2" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "6.12.6", "ajv-keywords": "3.5.2" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
|
||||||
|
|
||||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "10.28.3" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
|
||||||
|
|
||||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "5.48.1" }, "optionalDependencies": { "search-insights": "2.17.3" } }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "5.1.0", "glob": "7.2.3", "minimatch": "3.1.2" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "5.1.0", "glob": "7.2.3", "minimatch": "3.1.2" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "4.1.2", "fs-extra": "9.1.0", "minimist": "1.2.8" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "4.1.2", "fs-extra": "9.1.0", "minimist": "1.2.8" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||||
@@ -164,186 +95,34 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
"@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.70", "", { "dependencies": { "@iconify/types": "2.0.0" } }, "sha512-CYNRCgN6nBTjN4dNkrBCjHXNR2e4hQihdsZUs/afUNFOWLSYjfihca4EFN05rRvDk4Xoy2n8tym6IxBZmcn+Qg=="],
|
|
||||||
|
|
||||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
|
||||||
|
|
||||||
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "1.1.0", "@iconify/types": "2.0.0", "mlly": "1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
|
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
||||||
|
|
||||||
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
|
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
|
||||||
|
|
||||||
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "4.4.3", "fs-extra": "9.1.0", "lodash": "4.17.23", "tmp-promise": "3.0.3" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
|
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "4.4.3", "fs-extra": "9.1.0", "lodash": "4.17.23", "tmp-promise": "3.0.3" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
|
||||||
|
|
||||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
|
|
||||||
|
|
||||||
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "7.1.4", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "lru-cache": "10.4.3", "socks-proxy-agent": "8.0.5" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "7.1.4", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "lru-cache": "10.4.3", "socks-proxy-agent": "8.0.5" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||||
|
|
||||||
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
||||||
|
|
||||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
|
||||||
|
|
||||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4", "hast-util-to-html": "9.0.5" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
|
||||||
|
|
||||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "10.0.2", "oniguruma-to-es": "3.1.1" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
|
||||||
|
|
||||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="],
|
|
||||||
|
|
||||||
"@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="],
|
|
||||||
|
|
||||||
"@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="],
|
|
||||||
|
|
||||||
"@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="],
|
|
||||||
|
|
||||||
"@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="],
|
|
||||||
|
|
||||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
|
||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "2.0.1" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "2.0.1" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
||||||
|
|
||||||
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "4.2.0", "@types/keyv": "3.1.4", "@types/node": "25.2.3", "@types/responselike": "1.0.3" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "4.2.0", "@types/keyv": "3.1.4", "@types/node": "25.2.3", "@types/responselike": "1.0.3" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
|
||||||
|
|
||||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "3.2.2", "@types/d3-axis": "3.0.6", "@types/d3-brush": "3.0.6", "@types/d3-chord": "3.0.6", "@types/d3-color": "3.1.3", "@types/d3-contour": "3.0.6", "@types/d3-delaunay": "6.0.4", "@types/d3-dispatch": "3.0.7", "@types/d3-drag": "3.0.7", "@types/d3-dsv": "3.0.7", "@types/d3-ease": "3.0.2", "@types/d3-fetch": "3.0.7", "@types/d3-force": "3.0.10", "@types/d3-format": "3.0.4", "@types/d3-geo": "3.1.0", "@types/d3-hierarchy": "3.1.7", "@types/d3-interpolate": "3.0.4", "@types/d3-path": "3.1.1", "@types/d3-polygon": "3.0.2", "@types/d3-quadtree": "3.0.6", "@types/d3-random": "3.0.3", "@types/d3-scale": "4.0.9", "@types/d3-scale-chromatic": "3.1.0", "@types/d3-selection": "3.0.11", "@types/d3-shape": "3.1.8", "@types/d3-time": "3.0.4", "@types/d3-time-format": "4.0.3", "@types/d3-timer": "3.0.2", "@types/d3-transition": "3.0.9", "@types/d3-zoom": "3.0.8" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
|
||||||
|
|
||||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
|
||||||
|
|
||||||
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "3.0.11" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
|
|
||||||
|
|
||||||
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "3.0.11" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
|
|
||||||
|
|
||||||
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
|
|
||||||
|
|
||||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
|
||||||
|
|
||||||
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "3.2.2", "@types/geojson": "7946.0.16" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
|
|
||||||
|
|
||||||
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
|
|
||||||
|
|
||||||
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
|
|
||||||
|
|
||||||
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "3.0.11" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
|
||||||
|
|
||||||
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
|
|
||||||
|
|
||||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
|
||||||
|
|
||||||
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "3.0.7" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
|
|
||||||
|
|
||||||
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
|
||||||
|
|
||||||
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
|
|
||||||
|
|
||||||
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "7946.0.16" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
|
|
||||||
|
|
||||||
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
|
|
||||||
|
|
||||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "3.1.3" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
|
||||||
|
|
||||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
|
||||||
|
|
||||||
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
|
|
||||||
|
|
||||||
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
|
|
||||||
|
|
||||||
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
|
|
||||||
|
|
||||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "3.0.4" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
|
||||||
|
|
||||||
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
|
|
||||||
|
|
||||||
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
|
|
||||||
|
|
||||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "3.1.1" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
|
||||||
|
|
||||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
|
||||||
|
|
||||||
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
|
|
||||||
|
|
||||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
|
||||||
|
|
||||||
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "3.0.11" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
|
|
||||||
|
|
||||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "3.0.4", "@types/d3-selection": "3.0.11" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
|
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
|
||||||
|
|
||||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
|
||||||
|
|
||||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
|
||||||
|
|
||||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
||||||
|
|
||||||
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
||||||
|
|
||||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
|
||||||
|
|
||||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "5.0.0", "@types/mdurl": "2.0.0" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
|
||||||
|
|
||||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
|
||||||
|
|
||||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
|
||||||
|
|
||||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
|
||||||
@@ -352,68 +131,22 @@
|
|||||||
|
|
||||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
|
||||||
|
|
||||||
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||||
|
|
||||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "25.2.3" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||||
|
|
||||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
|
||||||
|
|
||||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "5.4.21", "vue": "3.5.28" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
|
||||||
|
|
||||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.28", "", { "dependencies": { "@babel/parser": "7.29.0", "@vue/shared": "3.5.28", "entities": "7.0.1", "estree-walker": "2.0.2", "source-map-js": "1.2.1" } }, "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ=="],
|
|
||||||
|
|
||||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.28", "", { "dependencies": { "@vue/compiler-core": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA=="],
|
|
||||||
|
|
||||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.28", "", { "dependencies": { "@babel/parser": "7.29.0", "@vue/compiler-core": "3.5.28", "@vue/compiler-dom": "3.5.28", "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28", "estree-walker": "2.0.2", "magic-string": "0.30.21", "postcss": "8.5.6", "source-map-js": "1.2.1" } }, "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g=="],
|
|
||||||
|
|
||||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g=="],
|
|
||||||
|
|
||||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
|
||||||
|
|
||||||
"@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "7.7.9", "birpc": "2.9.0", "hookable": "5.5.3", "mitt": "3.0.1", "perfect-debounce": "1.0.0", "speakingurl": "14.0.1", "superjson": "2.2.6" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="],
|
|
||||||
|
|
||||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
|
||||||
|
|
||||||
"@vue/reactivity": ["@vue/reactivity@3.5.28", "", { "dependencies": { "@vue/shared": "3.5.28" } }, "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw=="],
|
|
||||||
|
|
||||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/shared": "3.5.28" } }, "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ=="],
|
|
||||||
|
|
||||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.28", "", { "dependencies": { "@vue/reactivity": "3.5.28", "@vue/runtime-core": "3.5.28", "@vue/shared": "3.5.28", "csstype": "3.2.3" } }, "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA=="],
|
|
||||||
|
|
||||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.28", "", { "dependencies": { "@vue/compiler-ssr": "3.5.28", "@vue/shared": "3.5.28" }, "peerDependencies": { "vue": "3.5.28" } }, "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg=="],
|
|
||||||
|
|
||||||
"@vue/shared": ["@vue/shared@3.5.28", "", {}, "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ=="],
|
|
||||||
|
|
||||||
"@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "3.5.28" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="],
|
|
||||||
|
|
||||||
"@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "3.5.28" }, "optionalDependencies": { "axios": "1.13.5", "focus-trap": "7.8.0" } }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="],
|
|
||||||
|
|
||||||
"@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="],
|
|
||||||
|
|
||||||
"@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "3.5.28" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="],
|
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||||
|
|
||||||
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "6.12.6" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "6.12.6" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||||
|
|
||||||
"algoliasearch": ["algoliasearch@5.48.1", "", { "dependencies": { "@algolia/abtesting": "1.14.1", "@algolia/client-abtesting": "5.48.1", "@algolia/client-analytics": "5.48.1", "@algolia/client-common": "5.48.1", "@algolia/client-insights": "5.48.1", "@algolia/client-personalization": "5.48.1", "@algolia/client-query-suggestions": "5.48.1", "@algolia/client-search": "5.48.1", "@algolia/ingestion": "1.48.1", "@algolia/monitoring": "1.48.1", "@algolia/recommend": "5.48.1", "@algolia/requester-browser-xhr": "5.48.1", "@algolia/requester-fetch": "5.48.1", "@algolia/requester-node-http": "5.48.1" } }, "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -444,8 +177,6 @@
|
|||||||
|
|
||||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
|
||||||
|
|
||||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "5.7.1", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "5.7.1", "inherits": "2.0.4", "readable-stream": "3.6.2" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
@@ -470,18 +201,8 @@
|
|||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
|
||||||
|
|
||||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
|
||||||
|
|
||||||
"chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "4.17.23" }, "peerDependencies": { "chevrotain": "11.0.3" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="],
|
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
"chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="],
|
||||||
@@ -506,104 +227,20 @@
|
|||||||
|
|
||||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
|
"compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="],
|
||||||
|
|
||||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
|
||||||
|
|
||||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "5.5.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "1.0.2" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
|
|
||||||
|
|
||||||
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "5.7.1" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
|
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "5.7.1" } }, "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.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="],
|
|
||||||
|
|
||||||
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "1.0.3" }, "peerDependencies": { "cytoscape": "3.33.1" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
|
|
||||||
|
|
||||||
"cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "2.2.0" }, "peerDependencies": { "cytoscape": "3.33.1" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
|
|
||||||
|
|
||||||
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3.2.4", "d3-axis": "3.0.0", "d3-brush": "3.0.0", "d3-chord": "3.0.1", "d3-color": "3.1.0", "d3-contour": "4.0.2", "d3-delaunay": "6.0.4", "d3-dispatch": "3.0.1", "d3-drag": "3.0.0", "d3-dsv": "3.0.1", "d3-ease": "3.0.1", "d3-fetch": "3.0.1", "d3-force": "3.0.0", "d3-format": "3.1.2", "d3-geo": "3.1.1", "d3-hierarchy": "3.1.2", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-polygon": "3.0.1", "d3-quadtree": "3.0.1", "d3-random": "3.0.1", "d3-scale": "4.0.2", "d3-scale-chromatic": "3.1.0", "d3-selection": "3.0.0", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "d3-timer": "3.0.1", "d3-transition": "3.0.1", "d3-zoom": "3.0.0" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
|
|
||||||
|
|
||||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "2.0.3" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
|
||||||
|
|
||||||
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
|
|
||||||
|
|
||||||
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "3.0.1", "d3-drag": "3.0.0", "d3-interpolate": "3.0.1", "d3-selection": "3.0.0", "d3-transition": "3.0.1" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
|
|
||||||
|
|
||||||
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "3.1.0" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
|
|
||||||
|
|
||||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
|
||||||
|
|
||||||
"d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "3.2.4" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
|
|
||||||
|
|
||||||
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5.0.1" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
|
|
||||||
|
|
||||||
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
|
||||||
|
|
||||||
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "3.0.1", "d3-selection": "3.0.0" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
|
||||||
|
|
||||||
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7.2.0", "iconv-lite": "0.6.3", "rw": "1.3.3" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
|
|
||||||
|
|
||||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
|
||||||
|
|
||||||
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "3.0.1" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
|
|
||||||
|
|
||||||
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "3.0.1", "d3-quadtree": "3.0.1", "d3-timer": "3.0.1" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
|
||||||
|
|
||||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
|
||||||
|
|
||||||
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "3.2.4" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
|
||||||
|
|
||||||
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
|
|
||||||
|
|
||||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "3.1.0" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
|
||||||
|
|
||||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
|
||||||
|
|
||||||
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
|
|
||||||
|
|
||||||
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
|
||||||
|
|
||||||
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
|
|
||||||
|
|
||||||
"d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "2.12.1", "d3-shape": "1.3.7" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
|
|
||||||
|
|
||||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "3.2.4", "d3-format": "3.1.2", "d3-interpolate": "3.0.1", "d3-time": "3.1.0", "d3-time-format": "4.1.0" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
|
||||||
|
|
||||||
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "3.1.0", "d3-interpolate": "3.0.1" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
|
|
||||||
|
|
||||||
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
|
||||||
|
|
||||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
|
||||||
|
|
||||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "3.2.4" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
|
||||||
|
|
||||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "3.1.0" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
|
||||||
|
|
||||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
|
||||||
|
|
||||||
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "3.1.0", "d3-dispatch": "3.0.1", "d3-ease": "3.0.1", "d3-interpolate": "3.0.1", "d3-timer": "3.0.1" }, "peerDependencies": { "d3-selection": "3.0.0" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
|
||||||
|
|
||||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "3.0.1", "d3-drag": "3.0.0", "d3-interpolate": "3.0.1", "d3-selection": "3.0.0", "d3-transition": "3.0.1" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
|
||||||
|
|
||||||
"dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "7.9.0", "lodash-es": "4.17.23" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="],
|
|
||||||
|
|
||||||
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
@@ -616,18 +253,12 @@
|
|||||||
|
|
||||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "1.1.4", "has-property-descriptors": "1.0.2", "object-keys": "1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "1.1.4", "has-property-descriptors": "1.0.2", "object-keys": "1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||||
|
|
||||||
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
|
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
"detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||||
|
|
||||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
|
||||||
|
|
||||||
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "3.1.2", "p-limit": "3.1.0" } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
|
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "3.1.2", "p-limit": "3.1.0" } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
|
||||||
|
|
||||||
"discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="],
|
"discord-rpc": ["discord-rpc@4.0.1", "", { "dependencies": { "node-fetch": "^2.6.1", "ws": "^7.3.1" }, "optionalDependencies": { "register-scheme": "github:devsnek/node-register-scheme" } }, "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA=="],
|
||||||
@@ -636,8 +267,6 @@
|
|||||||
|
|
||||||
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "3.0.5", "@types/verror": "1.10.11", "ajv": "6.12.6", "crc": "3.8.0", "iconv-corefoundation": "1.1.7", "plist": "3.1.0", "smart-buffer": "4.2.0", "verror": "1.10.1" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "3.0.5", "@types/verror": "1.10.11", "ajv": "6.12.6", "crc": "3.8.0", "iconv-corefoundation": "1.1.7", "plist": "3.1.0", "smart-buffer": "4.2.0", "verror": "1.10.1" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
||||||
|
|
||||||
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
|
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "16.6.1" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "16.6.1" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||||
@@ -660,14 +289,10 @@
|
|||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="],
|
|
||||||
|
|
||||||
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "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=="],
|
||||||
|
|
||||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
|
||||||
|
|
||||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||||
@@ -688,8 +313,6 @@
|
|||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "4.4.3", "get-stream": "5.2.0", "yauzl": "2.10.0" }, "optionalDependencies": { "@types/yauzl": "2.10.3" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "4.4.3", "get-stream": "5.2.0", "yauzl": "2.10.0" }, "optionalDependencies": { "@types/yauzl": "2.10.3" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||||
@@ -708,8 +331,6 @@
|
|||||||
|
|
||||||
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "5.1.6" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
|
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "5.1.6" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
|
||||||
|
|
||||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
|
||||||
|
|
||||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||||
|
|
||||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "7.0.6", "signal-exit": "4.1.0" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "7.0.6", "signal-exit": "4.1.0" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
@@ -722,8 +343,6 @@
|
|||||||
|
|
||||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||||
@@ -746,8 +365,6 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "1.0.1" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "1.0.1" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||||
@@ -758,16 +375,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "3.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "comma-separated-tokens": "2.0.3", "hast-util-whitespace": "3.0.0", "html-void-elements": "3.0.0", "mdast-util-to-hast": "13.2.1", "property-information": "7.1.0", "space-separated-tokens": "2.0.2", "stringify-entities": "4.0.4", "zwitch": "2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
|
||||||
|
|
||||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "3.0.4" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
|
||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
|
||||||
|
|
||||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||||
|
|
||||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
|
||||||
|
|
||||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "7.1.4", "debug": "4.4.3" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
@@ -788,8 +397,6 @@
|
|||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
|
||||||
|
|
||||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -798,8 +405,6 @@
|
|||||||
|
|
||||||
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
||||||
|
|
||||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -824,54 +429,24 @@
|
|||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "2.0.1" }, "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||||
|
|
||||||
"katex": ["katex@0.16.28", "", { "dependencies": { "commander": "8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg=="],
|
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
|
||||||
|
|
||||||
"langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="],
|
|
||||||
|
|
||||||
"layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
|
|
||||||
|
|
||||||
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||||
|
|
||||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
|
||||||
|
|
||||||
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "4.1.2", "is-unicode-supported": "0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "4.1.2", "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-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
|
||||||
|
|
||||||
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "3.0.0", "cacache": "19.0.1", "http-cache-semantics": "4.2.0", "minipass": "7.1.2", "minipass-fetch": "4.0.1", "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=="],
|
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "3.0.0", "cacache": "19.0.1", "http-cache-semantics": "4.2.0", "minipass": "7.1.2", "minipass-fetch": "4.0.1", "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=="],
|
||||||
|
|
||||||
"mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="],
|
|
||||||
|
|
||||||
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@ungap/structured-clone": "1.3.0", "devlop": "1.1.0", "micromark-util-sanitize-uri": "2.0.1", "trim-lines": "3.0.1", "unist-util-position": "5.0.0", "unist-util-visit": "5.1.0", "vfile": "6.0.3" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
|
||||||
|
|
||||||
"mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="],
|
|
||||||
|
|
||||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
|
||||||
|
|
||||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
|
||||||
|
|
||||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-encode": "2.0.1", "micromark-util-symbol": "2.0.1" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
|
||||||
|
|
||||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
|
||||||
|
|
||||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
|
||||||
|
|
||||||
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||||
|
|
||||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
@@ -898,20 +473,12 @@
|
|||||||
|
|
||||||
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "3.3.6" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "3.3.6" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
||||||
|
|
||||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
|
||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
|
|
||||||
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "8.15.0", "pathe": "2.0.3", "pkg-types": "1.3.1", "ufo": "1.6.3" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
|
||||||
|
|
||||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"node-abi": ["node-abi@4.26.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw=="],
|
"node-abi": ["node-abi@4.26.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw=="],
|
||||||
@@ -934,8 +501,6 @@
|
|||||||
|
|
||||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||||
|
|
||||||
"oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "1.0.0", "regex": "6.1.0", "regex-recursion": "6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="],
|
|
||||||
|
|
||||||
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "4.1.0", "chalk": "4.1.2", "cli-cursor": "3.1.0", "cli-spinners": "2.9.2", "is-interactive": "1.0.0", "is-unicode-supported": "0.1.0", "log-symbols": "4.1.0", "strip-ansi": "6.0.1", "wcwidth": "1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "4.1.0", "chalk": "4.1.2", "cli-cursor": "3.1.0", "cli-spinners": "2.9.2", "is-interactive": "1.0.0", "is-unicode-supported": "0.1.0", "log-symbols": "4.1.0", "strip-ansi": "6.0.1", "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=="],
|
||||||
@@ -946,42 +511,24 @@
|
|||||||
|
|
||||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
|
||||||
|
|
||||||
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+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.4.3", "minipass": "7.1.2" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "10.4.3", "minipass": "7.1.2" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
|
||||||
|
|
||||||
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
||||||
|
|
||||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||||
|
|
||||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "0.1.8", "mlly": "1.8.0", "pathe": "2.0.3" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
|
||||||
|
|
||||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "0.8.11", "base64-js": "1.5.1", "xmlbuilder": "15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "0.8.11", "base64-js": "1.5.1", "xmlbuilder": "15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||||
|
|
||||||
"points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
|
|
||||||
|
|
||||||
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
|
||||||
|
|
||||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "9.5.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "9.5.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||||
|
|
||||||
"preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="],
|
|
||||||
|
|
||||||
"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@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||||
@@ -992,8 +539,6 @@
|
|||||||
|
|
||||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "4.2.11", "retry": "0.12.0", "signal-exit": "3.0.7" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "4.2.11", "retry": "0.12.0", "signal-exit": "3.0.7" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||||
|
|
||||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|
||||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "1.4.5", "once": "1.4.0" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||||
@@ -1006,12 +551,6 @@
|
|||||||
|
|
||||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "2.0.4", "string_decoder": "1.3.0", "util-deprecate": "1.0.2" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "2.0.4", "string_decoder": "1.3.0", "util-deprecate": "1.0.2" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
|
||||||
|
|
||||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
|
||||||
|
|
||||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
|
||||||
|
|
||||||
"register-scheme": ["register-scheme@github:devsnek/node-register-scheme#e7cc9a6", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.3.0" } }, "devsnek-node-register-scheme-e7cc9a6"],
|
"register-scheme": ["register-scheme@github:devsnek/node-register-scheme#e7cc9a6", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.3.0" } }, "devsnek-node-register-scheme-e7cc9a6"],
|
||||||
|
|
||||||
"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=="],
|
||||||
@@ -1026,20 +565,10 @@
|
|||||||
|
|
||||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||||
|
|
||||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
|
||||||
|
|
||||||
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "7.2.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "7.2.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||||
|
|
||||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "3.2.0", "detect-node": "2.1.0", "globalthis": "1.0.4", "json-stringify-safe": "5.0.1", "semver-compare": "1.0.0", "sprintf-js": "1.1.3" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "3.2.0", "detect-node": "2.1.0", "globalthis": "1.0.4", "json-stringify-safe": "5.0.1", "semver-compare": "1.0.0", "sprintf-js": "1.1.3" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||||
|
|
||||||
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
|
||||||
|
|
||||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
|
||||||
|
|
||||||
"roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "0.5.2", "path-data-parser": "0.1.0", "points-on-curve": "0.2.0", "points-on-path": "0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
|
|
||||||
|
|
||||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
@@ -1048,8 +577,6 @@
|
|||||||
|
|
||||||
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
||||||
|
|
||||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||||
@@ -1060,8 +587,6 @@
|
|||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="],
|
|
||||||
|
|
||||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||||
|
|
||||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "7.7.4" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||||
@@ -1076,14 +601,8 @@
|
|||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "1.1.2", "source-map": "0.6.1" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "1.1.2", "source-map": "0.6.1" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
|
||||||
|
|
||||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
|
||||||
|
|
||||||
"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.1.2" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
||||||
@@ -1096,22 +615,14 @@
|
|||||||
|
|
||||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "2.1.0", "character-entities-legacy": "3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
|
||||||
|
|
||||||
"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=="],
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
|
|
||||||
|
|
||||||
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "4.4.3" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "4.4.3" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
||||||
|
|
||||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "4.0.5" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
|
||||||
|
|
||||||
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "4.0.1", "chownr": "3.0.0", "minipass": "7.1.2", "minizlib": "3.1.0", "yallist": "5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
|
"tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "4.0.1", "chownr": "3.0.0", "minipass": "7.1.2", "minizlib": "3.1.0", "yallist": "5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
|
||||||
|
|
||||||
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "0.5.6", "rimraf": "2.6.3" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "0.5.6", "rimraf": "2.6.3" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
||||||
@@ -1120,8 +631,6 @@
|
|||||||
|
|
||||||
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "5.7.2" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "5.7.2" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||||
@@ -1130,34 +639,18 @@
|
|||||||
|
|
||||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
|
||||||
|
|
||||||
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
|
"truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="],
|
||||||
|
|
||||||
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
|
|
||||||
|
|
||||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
|
"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=="],
|
"unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="],
|
||||||
|
|
||||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
|
||||||
|
|
||||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
|
||||||
|
|
||||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
|
||||||
|
|
||||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1", "unist-util-visit-parents": "6.0.2" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
|
||||||
|
|
||||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "2.3.1" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
@@ -1166,32 +659,8 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
|
||||||
|
|
||||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "1.0.0", "core-util-is": "1.0.2", "extsprintf": "1.4.1" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "1.0.0", "core-util-is": "1.0.2", "extsprintf": "1.4.1" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||||
|
|
||||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile-message": "4.0.3" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
|
||||||
|
|
||||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
|
||||||
|
|
||||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.57.1" }, "optionalDependencies": { "@types/node": "25.2.3", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
|
||||||
|
|
||||||
"vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "1.2.70", "@shikijs/core": "2.5.0", "@shikijs/transformers": "2.5.0", "@shikijs/types": "2.5.0", "@types/markdown-it": "14.1.2", "@vitejs/plugin-vue": "5.2.4", "@vue/devtools-api": "7.7.9", "@vue/shared": "3.5.28", "@vueuse/core": "12.8.2", "@vueuse/integrations": "12.8.2", "focus-trap": "7.8.0", "mark.js": "8.11.1", "minisearch": "7.2.0", "shiki": "2.5.0", "vite": "5.4.21", "vue": "3.5.28" }, "optionalDependencies": { "postcss": "8.5.6" }, "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="],
|
|
||||||
|
|
||||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
|
||||||
|
|
||||||
"vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="],
|
|
||||||
|
|
||||||
"vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
|
|
||||||
|
|
||||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
|
||||||
|
|
||||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
|
|
||||||
|
|
||||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
|
||||||
|
|
||||||
"vue": ["vue@3.5.28", "", { "dependencies": { "@vue/compiler-dom": "3.5.28", "@vue/compiler-sfc": "3.5.28", "@vue/runtime-dom": "3.5.28", "@vue/server-renderer": "3.5.28", "@vue/shared": "3.5.28" }, "optionalDependencies": { "typescript": "5.9.3" } }, "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg=="],
|
|
||||||
|
|
||||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "1.0.4" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "1.0.4" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||||
|
|
||||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
@@ -1222,8 +691,6 @@
|
|||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
|
|
||||||
"@electron/asar/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"@electron/asar/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
@@ -1274,20 +741,10 @@
|
|||||||
|
|
||||||
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="],
|
|
||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "2.0.1" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
|
|
||||||
|
|
||||||
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
|
||||||
|
|
||||||
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "1.0.1" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
|
|
||||||
|
|
||||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1.0.9" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
|
||||||
|
|
||||||
"dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
"dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "1.1.12" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||||
|
|
||||||
"discord-rpc/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
"discord-rpc/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||||
@@ -1308,8 +765,6 @@
|
|||||||
|
|
||||||
"global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
|
||||||
|
|
||||||
"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-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
@@ -1332,10 +787,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|
||||||
"vite/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
|
||||||
|
|
||||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "4.2.11" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
@@ -1366,26 +817,8 @@
|
|||||||
|
|
||||||
"cacache/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "2.0.2" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"cacache/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "2.0.2" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
|
|
||||||
|
|
||||||
"chevrotain-allstar/chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
|
||||||
|
|
||||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
|
||||||
|
|
||||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
|
||||||
|
|
||||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
|
||||||
|
|
||||||
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "4.4.3", "env-paths": "2.2.1", "fs-extra": "8.1.0", "got": "11.8.6", "progress": "2.0.3", "semver": "6.3.1", "sumchecker": "3.0.1" }, "optionalDependencies": { "global-agent": "3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
"electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "4.4.3", "env-paths": "2.2.1", "fs-extra": "8.1.0", "got": "11.8.6", "progress": "2.0.3", "semver": "6.3.1", "sumchecker": "3.0.1" }, "optionalDependencies": { "global-agent": "3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||||
@@ -1414,54 +847,6 @@
|
|||||||
|
|
||||||
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"vite/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
|
||||||
|
|
||||||
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
"@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
const repositoryName = process.env.GITHUB_REPOSITORY?.split('/')[1];
|
|
||||||
const base = process.env.GITHUB_ACTIONS && repositoryName ? `/${repositoryName}/` : '/';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'SubMiner Docs',
|
|
||||||
description:
|
|
||||||
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
|
||||||
base,
|
|
||||||
head: [
|
|
||||||
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
|
|
||||||
[
|
|
||||||
'link',
|
|
||||||
{
|
|
||||||
rel: 'icon',
|
|
||||||
type: 'image/png',
|
|
||||||
href: '/favicon-32x32.png',
|
|
||||||
sizes: '32x32',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'link',
|
|
||||||
{
|
|
||||||
rel: 'icon',
|
|
||||||
type: 'image/png',
|
|
||||||
href: '/favicon-16x16.png',
|
|
||||||
sizes: '16x16',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'link',
|
|
||||||
{
|
|
||||||
rel: 'apple-touch-icon',
|
|
||||||
href: '/apple-touch-icon.png',
|
|
||||||
sizes: '180x180',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
appearance: 'dark',
|
|
||||||
cleanUrls: true,
|
|
||||||
metaChunk: true,
|
|
||||||
sitemap: { hostname: 'https://docs.subminer.moe' },
|
|
||||||
lastUpdated: true,
|
|
||||||
srcExclude: ['subagents/**'],
|
|
||||||
markdown: {
|
|
||||||
theme: {
|
|
||||||
light: 'catppuccin-latte',
|
|
||||||
dark: 'catppuccin-macchiato',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
themeConfig: {
|
|
||||||
logo: {
|
|
||||||
light: '/assets/SubMiner.png',
|
|
||||||
dark: '/assets/SubMiner.png',
|
|
||||||
},
|
|
||||||
siteTitle: 'SubMiner Docs',
|
|
||||||
nav: [
|
|
||||||
{ text: 'Home', link: '/' },
|
|
||||||
{ text: 'Get Started', link: '/installation' },
|
|
||||||
{ text: 'Mining', link: '/mining-workflow' },
|
|
||||||
{ text: 'Configuration', link: '/configuration' },
|
|
||||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
|
||||||
],
|
|
||||||
sidebar: [
|
|
||||||
{
|
|
||||||
text: 'Getting Started',
|
|
||||||
items: [
|
|
||||||
{ text: 'Overview', link: '/' },
|
|
||||||
{ text: 'Installation', link: '/installation' },
|
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
|
||||||
{ text: 'Usage', link: '/usage' },
|
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
|
||||||
// { text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Reference',
|
|
||||||
items: [
|
|
||||||
{ text: 'Configuration', link: '/configuration' },
|
|
||||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
|
||||||
{ text: 'Anki Integration', link: '/anki-integration' },
|
|
||||||
{ text: 'Jellyfin Integration', link: '/jellyfin-integration' },
|
|
||||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
|
||||||
{ text: 'JLPT Vocabulary', link: '/jlpt-vocab-bundle' },
|
|
||||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
|
||||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Development',
|
|
||||||
items: [
|
|
||||||
{ text: 'Building & Testing', link: '/development' },
|
|
||||||
{ text: 'Architecture', link: '/architecture' },
|
|
||||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
search: {
|
|
||||||
provider: 'local',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
message: 'Released under the GPL-3.0 License.',
|
|
||||||
copyright: 'Copyright © 2026-present sudacode',
|
|
||||||
},
|
|
||||||
editLink: {
|
|
||||||
pattern: 'https://github.com/ksyasuda/SubMiner/edit/main/docs/:path',
|
|
||||||
text: 'Edit this page on GitHub',
|
|
||||||
},
|
|
||||||
outline: { level: [2, 3], label: 'On this page' },
|
|
||||||
externalLinkIcon: true,
|
|
||||||
docFooter: { prev: 'Previous', next: 'Next' },
|
|
||||||
returnToTopLabel: 'Back to top',
|
|
||||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import DefaultTheme from 'vitepress/theme';
|
|
||||||
import { useRoute } from 'vitepress';
|
|
||||||
import { nextTick, onMounted, watch } from 'vue';
|
|
||||||
import '@catppuccin/vitepress/theme/macchiato/mauve.css';
|
|
||||||
import './mermaid-modal.css';
|
|
||||||
|
|
||||||
let mermaidLoader: Promise<any> | null = null;
|
|
||||||
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
|
|
||||||
|
|
||||||
function closeMermaidModal() {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById(MERMAID_MODAL_ID);
|
|
||||||
if (!modal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.classList.remove('is-open');
|
|
||||||
document.body.classList.remove('mermaid-modal-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureMermaidModal(): HTMLDivElement {
|
|
||||||
const existing = document.getElementById(MERMAID_MODAL_ID);
|
|
||||||
if (existing) {
|
|
||||||
return existing as HTMLDivElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.id = MERMAID_MODAL_ID;
|
|
||||||
modal.className = 'mermaid-modal';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="mermaid-modal__backdrop" data-mermaid-close="true"></div>
|
|
||||||
<div class="mermaid-modal__dialog" role="dialog" aria-modal="true" aria-label="Expanded Mermaid diagram">
|
|
||||||
<button class="mermaid-modal__close" type="button" aria-label="Close Mermaid diagram">Close</button>
|
|
||||||
<div class="mermaid-modal__content"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
modal.addEventListener('click', (event) => {
|
|
||||||
const target = event.target as HTMLElement | null;
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (target.closest('[data-mermaid-close="true"]') || target.closest('.mermaid-modal__close')) {
|
|
||||||
closeMermaidModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape' && modal.classList.contains('is-open')) {
|
|
||||||
closeMermaidModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
return modal;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMermaidModal(sourceNode: HTMLElement) {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = ensureMermaidModal();
|
|
||||||
const content = modal.querySelector<HTMLDivElement>('.mermaid-modal__content');
|
|
||||||
if (!content) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
content.replaceChildren(sourceNode.cloneNode(true));
|
|
||||||
modal.classList.add('is-open');
|
|
||||||
document.body.classList.add('mermaid-modal-open');
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachMermaidInteractions(nodes: HTMLElement[]) {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.dataset.mermaidInteractive === 'true') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svg = node.querySelector<HTMLElement>('svg');
|
|
||||||
if (!svg) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
node.classList.add('mermaid-interactive');
|
|
||||||
node.setAttribute('role', 'button');
|
|
||||||
node.setAttribute('tabindex', '0');
|
|
||||||
node.setAttribute('aria-label', 'Open Mermaid diagram in full view');
|
|
||||||
|
|
||||||
const open = () => openMermaidModal(svg);
|
|
||||||
node.addEventListener('click', open);
|
|
||||||
node.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
node.dataset.mermaidInteractive = 'true';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMermaid() {
|
|
||||||
if (!mermaidLoader) {
|
|
||||||
mermaidLoader = import('mermaid').then((module) => {
|
|
||||||
const mermaid = module.default;
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
securityLevel: 'loose',
|
|
||||||
theme: 'base',
|
|
||||||
themeVariables: {
|
|
||||||
background: '#24273a',
|
|
||||||
primaryColor: '#363a4f',
|
|
||||||
primaryTextColor: '#cad3f5',
|
|
||||||
primaryBorderColor: '#c6a0f6',
|
|
||||||
secondaryColor: '#494d64',
|
|
||||||
secondaryTextColor: '#cad3f5',
|
|
||||||
secondaryBorderColor: '#b7bdf8',
|
|
||||||
tertiaryColor: '#5b6078',
|
|
||||||
tertiaryTextColor: '#cad3f5',
|
|
||||||
tertiaryBorderColor: '#8aadf4',
|
|
||||||
lineColor: '#939ab7',
|
|
||||||
textColor: '#cad3f5',
|
|
||||||
mainBkg: '#363a4f',
|
|
||||||
nodeBorder: '#c6a0f6',
|
|
||||||
clusterBkg: '#1e2030',
|
|
||||||
clusterBorder: '#494d64',
|
|
||||||
edgeLabelBackground: '#24273a',
|
|
||||||
labelTextColor: '#cad3f5',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return mermaid;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return mermaidLoader;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderMermaidBlocks() {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blocks = Array.from(document.querySelectorAll<HTMLElement>('div.language-mermaid'));
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mermaid = await getMermaid();
|
|
||||||
const nodes: HTMLElement[] = [];
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block.dataset.mermaidRendered === 'true') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const code = block.querySelector('pre code');
|
|
||||||
const source = code?.textContent?.trim();
|
|
||||||
if (!source) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mount = document.createElement('div');
|
|
||||||
mount.className = 'mermaid';
|
|
||||||
mount.textContent = source;
|
|
||||||
|
|
||||||
block.replaceChildren(mount);
|
|
||||||
block.dataset.mermaidRendered = 'true';
|
|
||||||
nodes.push(mount);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodes.length > 0) {
|
|
||||||
await mermaid.run({ nodes });
|
|
||||||
attachMermaidInteractions(nodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
...DefaultTheme,
|
|
||||||
setup() {
|
|
||||||
const route = useRoute();
|
|
||||||
const render = () => {
|
|
||||||
nextTick(() => {
|
|
||||||
renderMermaidBlocks().catch((error) => {
|
|
||||||
console.error('Failed to render Mermaid diagram:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
watch(() => route.path, render);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
.mermaid-interactive {
|
|
||||||
cursor: zoom-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-interactive:focus-visible {
|
|
||||||
outline: 2px solid var(--vp-c-brand-1);
|
|
||||||
outline-offset: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 200;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal.is-open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal__dialog {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
margin: 4vh auto;
|
|
||||||
width: min(96vw, 1800px);
|
|
||||||
max-height: 92vh;
|
|
||||||
border: 1px solid var(--vp-c-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--vp-c-bg);
|
|
||||||
box-shadow: var(--vp-shadow-4);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal__close {
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: 16px;
|
|
||||||
margin-top: 12px;
|
|
||||||
border: 1px solid var(--vp-c-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: var(--vp-c-bg-soft);
|
|
||||||
color: var(--vp-c-text-1);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal__content {
|
|
||||||
overflow: auto;
|
|
||||||
max-height: calc(92vh - 56px);
|
|
||||||
padding: 8px 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mermaid-modal__content svg {
|
|
||||||
max-width: none;
|
|
||||||
width: max-content;
|
|
||||||
height: auto;
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mermaid-modal-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Documentation
|
|
||||||
|
|
||||||
SubMiner documentation is built with [VitePress](https://vitepress.dev/).
|
|
||||||
|
|
||||||
## Local Docs Site
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make docs-dev # Dev server at http://localhost:5173
|
|
||||||
make docs # Build static output
|
|
||||||
make docs-preview # Preview built site at http://localhost:4173
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup
|
|
||||||
- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings
|
|
||||||
- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, single overlay + modals, card creation
|
|
||||||
|
|
||||||
### Reference
|
|
||||||
|
|
||||||
- [Configuration](/configuration) — Full config file reference and option details
|
|
||||||
- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place
|
|
||||||
- [Anki Integration](/anki-integration) — AnkiConnect setup, proxy/polling transport, field mapping, media generation, field grouping
|
|
||||||
- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch
|
|
||||||
- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points
|
|
||||||
- [Performance & Tuning](/troubleshooting#performance-and-resource-impact) — Resource usage and practical low-impact profile
|
|
||||||
- [JLPT Vocabulary](/jlpt-vocab-bundle) — Bundled term-meta bank for JLPT level underlining and frequency highlighting
|
|
||||||
- [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages
|
|
||||||
- [Troubleshooting](/troubleshooting) — Common issues and solutions by category
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- [Building & Testing](/development) — Build commands, test suites, contributor notes, environment variables
|
|
||||||
- [Architecture](/architecture) — Service-oriented design, composition model, renderer module layout
|
|
||||||
- [IPC + Runtime Contracts](/ipc-contracts) — Main/renderer IPC contracts and contributor onboarding
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
# Anki Integration
|
|
||||||
|
|
||||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
|
||||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Install [Anki](https://apps.ankiweb.net/).
|
|
||||||
2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on (code: `2055492159`).
|
|
||||||
3. Keep Anki running while using SubMiner.
|
|
||||||
|
|
||||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
|
||||||
|
|
||||||
## Auto-Enrichment Transport
|
|
||||||
|
|
||||||
SubMiner supports two auto-enrichment transport modes:
|
|
||||||
|
|
||||||
1. `proxy` (default): runs a local AnkiConnect-compatible proxy and enriches cards immediately after successful `addNote` / `addNotes` / `multi` responses.
|
|
||||||
2. `polling`: polls AnkiConnect at `ankiConnect.pollingRate` (default: 3s).
|
|
||||||
|
|
||||||
In both modes, the enrichment workflow is the same:
|
|
||||||
|
|
||||||
1. Checks if a duplicate expression already exists (for field grouping).
|
|
||||||
2. Updates the sentence field with the current subtitle.
|
|
||||||
3. Generates and uploads audio and image media.
|
|
||||||
4. Fills the translation field from the secondary subtitle or AI.
|
|
||||||
5. Writes metadata to the miscInfo field.
|
|
||||||
|
|
||||||
Polling mode uses the query `"deck:<your-deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks.
|
|
||||||
|
|
||||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"url": "http://127.0.0.1:8765", // real AnkiConnect
|
|
||||||
"proxy": {
|
|
||||||
"enabled": true,
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": 8766,
|
|
||||||
"upstreamUrl": "http://127.0.0.1:8765"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
|
||||||
|
|
||||||
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **active bundled Yomitan profile** (`profiles[profileCurrent].options.anki.server`) to the active SubMiner endpoint:
|
|
||||||
|
|
||||||
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
|
||||||
- direct `ankiConnect.url` when proxy mode is disabled
|
|
||||||
|
|
||||||
Server update behavior differs by mode:
|
|
||||||
|
|
||||||
- Proxy mode (`ankiConnect.proxy.enabled: true`): SubMiner force-syncs the bundled active profile to the proxy URL so `addNote` traffic goes through the local proxy and auto-enrichment can trigger.
|
|
||||||
- Direct mode (`ankiConnect.proxy.enabled: false`): SubMiner only replaces blank/default server values (`http://127.0.0.1:8765`) to avoid overwriting custom direct-server setups.
|
|
||||||
|
|
||||||
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
|
||||||
|
|
||||||
### Browser/Yomitan external setup (separate profile)
|
|
||||||
|
|
||||||
If you want SubMiner to use proxy mode without touching your main/default Yomitan profile, create or select a separate Yomitan profile just for SubMiner and set its Anki server to the proxy URL.
|
|
||||||
|
|
||||||
That profile isolation gives you both benefits:
|
|
||||||
|
|
||||||
- SubMiner can auto-enrich immediately via proxy.
|
|
||||||
- Your default Yomitan profile keeps its existing Anki server setting.
|
|
||||||
|
|
||||||
In Yomitan, go to Settings → Profile and:
|
|
||||||
|
|
||||||
1. Create a profile for SubMiner (or choose one dedicated profile).
|
|
||||||
2. Open Anki settings for that profile.
|
|
||||||
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
|
||||||
4. Save and make that profile active when using SubMiner.
|
|
||||||
|
|
||||||
This is only for non-bundled, external/browser Yomitan or other clients. Bundled Yomitan profile sync behavior is described above (force-sync in proxy mode, conservative sync in direct mode).
|
|
||||||
|
|
||||||
### Proxy Troubleshooting (quick checks)
|
|
||||||
|
|
||||||
If auto-enrichment appears to do nothing:
|
|
||||||
|
|
||||||
1. Confirm proxy listener is running while SubMiner is active:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ss -ltnp | rg 8766
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Confirm requests can pass through the proxy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS http://127.0.0.1:8766 \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-d '{"action":"version","version":2}'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Check both log sinks:
|
|
||||||
|
|
||||||
- Launcher/mpv-integrated log: `~/.cache/SubMiner/mp.log`
|
|
||||||
- App runtime log: `~/.config/SubMiner/logs/SubMiner-YYYY-MM-DD.log`
|
|
||||||
|
|
||||||
4. Ensure config JSONC is valid and logging shape is correct:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"logging": {
|
|
||||||
"level": "debug"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
|
|
||||||
|
|
||||||
## Field Mapping
|
|
||||||
|
|
||||||
SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"fields": {
|
|
||||||
"audio": "ExpressionAudio", // audio clip from the video
|
|
||||||
"image": "Picture", // screenshot or animated clip
|
|
||||||
"sentence": "Sentence", // subtitle text
|
|
||||||
"miscInfo": "MiscInfo", // metadata (filename, timestamp)
|
|
||||||
"translation": "SelectionText" // secondary sub or AI translation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Field names must match your Anki note type exactly (case-sensitive). If a configured field does not exist on the note type, SubMiner skips it without error.
|
|
||||||
|
|
||||||
### Minimal Config
|
|
||||||
|
|
||||||
If you only want sentence and audio on your cards:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"enabled": true,
|
|
||||||
"fields": {
|
|
||||||
"sentence": "Sentence",
|
|
||||||
"audio": "ExpressionAudio"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Media Generation
|
|
||||||
|
|
||||||
SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`.
|
|
||||||
|
|
||||||
### Audio
|
|
||||||
|
|
||||||
Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"generateAudio": true,
|
|
||||||
"audioPadding": 0.5, // seconds before and after subtitle timing
|
|
||||||
"maxMediaDuration": 30 // cap total duration in seconds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
|
||||||
|
|
||||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
|
||||||
|
|
||||||
### Screenshots (Static)
|
|
||||||
|
|
||||||
A single frame is captured at the current playback position.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"generateImage": true,
|
|
||||||
"imageType": "static",
|
|
||||||
"imageFormat": "jpg", // "jpg", "png", or "webp"
|
|
||||||
"imageQuality": 92, // 1–100
|
|
||||||
"imageMaxWidth": null, // optional, preserves aspect ratio
|
|
||||||
"imageMaxHeight": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Animated Clips (AVIF)
|
|
||||||
|
|
||||||
Instead of a static screenshot, SubMiner can generate an animated AVIF covering the subtitle duration.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"generateImage": true,
|
|
||||||
"imageType": "avif",
|
|
||||||
"animatedFps": 10,
|
|
||||||
"animatedMaxWidth": 640,
|
|
||||||
"animatedMaxHeight": null,
|
|
||||||
"animatedCrf": 35 // 0–63, lower = better quality
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds.
|
|
||||||
|
|
||||||
### Behavior Options
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"behavior": {
|
|
||||||
"overwriteAudio": true, // replace existing audio, or append
|
|
||||||
"overwriteImage": true, // replace existing image, or append
|
|
||||||
"mediaInsertMode": "append", // "append" or "prepend" to field content
|
|
||||||
"autoUpdateNewCards": true, // auto-update when new card detected
|
|
||||||
"notificationType": "osd" // "osd", "system", "both", or "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## AI Translation
|
|
||||||
|
|
||||||
SubMiner can auto-translate the mined sentence and fill the translation field. By default, if a secondary subtitle track is available, its text is used. When AI is enabled, SubMiner calls an LLM API instead.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"ai": {
|
|
||||||
"enabled": true,
|
|
||||||
"alwaysUseAiTranslation": false, // true = ignore secondary sub
|
|
||||||
"apiKey": "sk-...",
|
|
||||||
"model": "openai/gpt-4o-mini",
|
|
||||||
"baseUrl": "https://openrouter.ai/api",
|
|
||||||
"targetLanguage": "English",
|
|
||||||
"systemPrompt": "You are a translation engine. Return only the translation."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Translation priority:
|
|
||||||
|
|
||||||
1. If `alwaysUseAiTranslation` is `true`, always call the AI API.
|
|
||||||
2. If a secondary subtitle is available, use it as the translation.
|
|
||||||
3. If AI is enabled and no secondary subtitle exists, call the AI API.
|
|
||||||
4. Otherwise, leave the field empty.
|
|
||||||
|
|
||||||
## Sentence Cards (Lapis)
|
|
||||||
|
|
||||||
SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"isLapis": {
|
|
||||||
"enabled": true,
|
|
||||||
"sentenceCardModel": "Japanese sentences"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
|
|
||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
|
||||||
|
|
||||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"isKiku": {
|
|
||||||
"enabled": true,
|
|
||||||
"fieldGrouping": "manual", // "auto", "manual", or "disabled"
|
|
||||||
"deleteDuplicateInAuto": true // delete new card after auto-merge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modes
|
|
||||||
|
|
||||||
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
|
||||||
|
|
||||||
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved, and exact duplicate values are collapsed to one entry. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
|
||||||
|
|
||||||
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
|
||||||
|
|
||||||
### What Gets Merged
|
|
||||||
|
|
||||||
| Field | Merge behavior |
|
|
||||||
| -------- | --------------------------------------------------------------- |
|
|
||||||
| Sentence | Both sentences preserved (exact duplicate text is deduplicated) |
|
|
||||||
| Audio | Both `[sound:...]` entries kept (exact duplicates deduplicated) |
|
|
||||||
| Image | Both images kept (exact duplicates deduplicated) |
|
|
||||||
|
|
||||||
### Keyboard Shortcuts in the Modal
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --------- | ---------------------------------- |
|
|
||||||
| `1` / `2` | Select card 1 or card 2 to keep |
|
|
||||||
| `Enter` | Confirm selection |
|
|
||||||
| `Esc` | Cancel (keep both cards unchanged) |
|
|
||||||
|
|
||||||
## Full Config Example
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"ankiConnect": {
|
|
||||||
"enabled": true,
|
|
||||||
"url": "http://127.0.0.1:8765",
|
|
||||||
"pollingRate": 3000,
|
|
||||||
"proxy": {
|
|
||||||
"enabled": false,
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": 8766,
|
|
||||||
"upstreamUrl": "http://127.0.0.1:8765",
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"audio": "ExpressionAudio",
|
|
||||||
"image": "Picture",
|
|
||||||
"sentence": "Sentence",
|
|
||||||
"miscInfo": "MiscInfo",
|
|
||||||
"translation": "SelectionText",
|
|
||||||
},
|
|
||||||
"media": {
|
|
||||||
"generateAudio": true,
|
|
||||||
"generateImage": true,
|
|
||||||
"imageType": "static",
|
|
||||||
"imageFormat": "jpg",
|
|
||||||
"imageQuality": 92,
|
|
||||||
"audioPadding": 0.5,
|
|
||||||
"maxMediaDuration": 30,
|
|
||||||
},
|
|
||||||
"behavior": {
|
|
||||||
"overwriteAudio": true,
|
|
||||||
"overwriteImage": true,
|
|
||||||
"mediaInsertMode": "append",
|
|
||||||
"autoUpdateNewCards": true,
|
|
||||||
"notificationType": "osd",
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"enabled": false,
|
|
||||||
"apiKey": "",
|
|
||||||
"model": "openai/gpt-4o-mini",
|
|
||||||
"baseUrl": "https://openrouter.ai/api",
|
|
||||||
"targetLanguage": "English",
|
|
||||||
},
|
|
||||||
"isKiku": {
|
|
||||||
"enabled": false,
|
|
||||||
"fieldGrouping": "disabled",
|
|
||||||
"deleteDuplicateInAuto": true,
|
|
||||||
},
|
|
||||||
"isLapis": {
|
|
||||||
"enabled": false,
|
|
||||||
"sentenceCardModel": "Japanese sentences",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
SubMiner is split into three cooperating runtimes:
|
|
||||||
|
|
||||||
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
|
||||||
- Launcher CLI (`launcher/`) for mpv/app command workflows.
|
|
||||||
- mpv Lua plugin (`plugin/subminer/init.lua` + module files) for player-side controls and IPC handoff.
|
|
||||||
|
|
||||||
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Keep behavior stable while reducing coupling.
|
|
||||||
- Prefer small, single-purpose units that can be tested in isolation.
|
|
||||||
- Keep `main.ts` focused on wiring and state ownership, not implementation detail.
|
|
||||||
- Follow Unix-style composability:
|
|
||||||
- each service does one job
|
|
||||||
- services compose through explicit inputs/outputs
|
|
||||||
- orchestration is separate from implementation
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
|
||||||
commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough)
|
|
||||||
config/ # Launcher config parsers + CLI parser builder
|
|
||||||
main.ts # Launcher entrypoint and command dispatch
|
|
||||||
plugin/
|
|
||||||
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
|
||||||
# state · messages · hover · ui · options · environment · log
|
|
||||||
# binary · aniskip · aniskip_match)
|
|
||||||
src/
|
|
||||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
|
||||||
main.ts # Entry point — delegates to runtime composers/domain modules
|
|
||||||
preload.ts # Electron preload bridge
|
|
||||||
types.ts # Shared type definitions
|
|
||||||
main/ # Main-process composition/runtime adapters
|
|
||||||
app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
|
|
||||||
cli-runtime.ts # CLI command runtime service adapters
|
|
||||||
config-validation.ts # Startup/hot-reload config error formatting and fail-fast helpers
|
|
||||||
dependencies.ts # Shared dependency builders for IPC/runtime services
|
|
||||||
ipc-runtime.ts # IPC runtime registration wrappers
|
|
||||||
overlay-runtime.ts # Overlay modal routing + active-window selection
|
|
||||||
overlay-shortcuts-runtime.ts # Overlay keyboard shortcut handling
|
|
||||||
overlay-visibility-runtime.ts # Overlay visibility + tracker-driven bounds service
|
|
||||||
frequency-dictionary-runtime.ts # Frequency dictionary runtime adapter
|
|
||||||
jlpt-runtime.ts # JLPT dictionary runtime adapter
|
|
||||||
media-runtime.ts # Media path/title/subtitle-position runtime service
|
|
||||||
startup.ts # Startup bootstrap dependency builder
|
|
||||||
startup-lifecycle.ts # Lifecycle runtime runner adapter
|
|
||||||
state.ts # Application runtime state container + reducer transitions
|
|
||||||
subsync-runtime.ts # Subsync command runtime adapter
|
|
||||||
runtime/
|
|
||||||
composers/ # High-level composition clusters used by main.ts
|
|
||||||
domains/ # Domain barrel exports (startup/overlay/mpv/jellyfin/...)
|
|
||||||
registry.ts # Domain registry consumed by main.ts
|
|
||||||
core/
|
|
||||||
services/ # Focused runtime services (Electron adapters + pure logic)
|
|
||||||
anilist/ # AniList token store/update queue/update helpers
|
|
||||||
immersion-tracker/ # Immersion persistence/session/metadata modules
|
|
||||||
tokenizer/ # Tokenizer stage modules (selection/enrichment/annotation)
|
|
||||||
utils/ # Pure helpers and coercion/config utilities
|
|
||||||
cli/ # CLI parsing and help output
|
|
||||||
config/ # Config defaults/definitions, loading, parse, resolution pipeline
|
|
||||||
definitions/ # Domain-specific defaults + option registries
|
|
||||||
resolve/ # Domain-specific config resolution pipeline stages
|
|
||||||
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
|
||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
|
||||||
handlers/ # Keyboard/mouse interaction modules
|
|
||||||
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
|
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
|
||||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
|
|
||||||
jimaku/ # Jimaku API integration helpers
|
|
||||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
|
||||||
subtitle/ # Subtitle processing utilities
|
|
||||||
tokenizers/ # Tokenizer implementations
|
|
||||||
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
|
|
||||||
token-mergers/ # Token merge strategies
|
|
||||||
translators/ # AI translation providers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service Layer (`src/core/services/`)
|
|
||||||
|
|
||||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
|
|
||||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
|
||||||
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
|
||||||
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
|
||||||
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
|
|
||||||
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
|
||||||
- **Anki integration:** `anki-integration.ts`, `anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `anki-integration/note-update-workflow.ts`
|
|
||||||
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
|
||||||
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
|
||||||
|
|
||||||
### Renderer Layer (`src/renderer/`)
|
|
||||||
|
|
||||||
The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delegated to per-concern modules.
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/renderer/
|
|
||||||
renderer.ts # Entrypoint/orchestration only
|
|
||||||
context.ts # Shared runtime context contract
|
|
||||||
state.ts # Centralized renderer mutable state (visible overlay only)
|
|
||||||
error-recovery.ts # Global renderer error boundary + recovery actions
|
|
||||||
overlay-content-measurement.ts # Reports rendered bounds to main process
|
|
||||||
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
|
||||||
positioning.ts # Facade export for positioning controller
|
|
||||||
yomitan-popup.ts # Yomitan popup iframe detection utilities
|
|
||||||
positioning/
|
|
||||||
controller.ts # Subtitle drag-position controller
|
|
||||||
position-state.ts # Position state helpers (yPercent)
|
|
||||||
handlers/
|
|
||||||
keyboard.ts # Keybindings, chord handling, modal key routing
|
|
||||||
mouse.ts # Hover/drag behavior, selection + observer wiring
|
|
||||||
modals/
|
|
||||||
jimaku.ts # Jimaku modal flow
|
|
||||||
kiku.ts # Kiku field-grouping modal flow
|
|
||||||
runtime-options.ts # Runtime options modal flow
|
|
||||||
session-help.ts # Keyboard shortcuts/help modal flow
|
|
||||||
subsync.ts # Manual subsync modal flow
|
|
||||||
utils/
|
|
||||||
dom.ts # Required DOM lookups + typed handles
|
|
||||||
platform.ts # Layer/platform capability detection
|
|
||||||
```
|
|
||||||
|
|
||||||
### Launcher + Plugin Runtimes
|
|
||||||
|
|
||||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
|
||||||
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
|
|
||||||
|
|
||||||
## Flow Diagram
|
|
||||||
|
|
||||||
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
|
|
||||||
classDef comp fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef svc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef bridge fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef rend fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
|
|
||||||
subgraph ExtRt["External Runtimes"]
|
|
||||||
Launcher["launcher/<br/>CLI dispatch"]:::extrt
|
|
||||||
Plugin["subminer/init.lua<br/>mpv plugin"]:::extrt
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Ext["External Systems"]
|
|
||||||
mpvExt["mpv player"]:::ext
|
|
||||||
AnkiExt["AnkiConnect"]:::ext
|
|
||||||
JimakuExt["Jimaku API"]:::ext
|
|
||||||
TrackerExt["Window Tracker<br/>Hyprland · Sway<br/>X11 · macOS"]:::ext
|
|
||||||
AnilistExt["AniList API"]:::ext
|
|
||||||
JellyfinExt["Jellyfin"]:::ext
|
|
||||||
DiscordExt["Discord RPC"]:::ext
|
|
||||||
end
|
|
||||||
|
|
||||||
Main["main.ts<br/>composition root"]:::entry
|
|
||||||
|
|
||||||
subgraph Comp["Composition — src/main/"]
|
|
||||||
Startup["Startup & Lifecycle<br/>startup · app-lifecycle<br/>startup-lifecycle · state"]:::comp
|
|
||||||
Wiring["Runtime Wiring<br/>ipc-runtime · cli-runtime<br/>overlay-runtime"]:::comp
|
|
||||||
Composers["Composers<br/>mpv · anilist<br/>jellyfin"]:::comp
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Svc["Services — src/core/services/"]
|
|
||||||
Mpv["MPV Stack<br/>transport · protocol<br/>properties · metrics"]:::svc
|
|
||||||
OverlaySvc["Overlay Manager<br/>window · visibility · bridge<br/>mpv-sub-visibility"]:::svc
|
|
||||||
Mining["Mining & Subtitles<br/>mining · field-grouping<br/>subtitle-ws · tokenizer"]:::svc
|
|
||||||
AnkiProxy["Anki Integration<br/>anki-connect-proxy<br/>note-update-workflow"]:::svc
|
|
||||||
Integrations["Integrations<br/>jimaku · subsync<br/>texthooker · yomitan"]:::svc
|
|
||||||
Tracking["Tracking<br/>anilist · jellyfin<br/>immersion · discord"]:::svc
|
|
||||||
Config["Config & Runtime<br/>hot-reload<br/>runtime-options"]:::svc
|
|
||||||
end
|
|
||||||
|
|
||||||
Bridge(["preload.ts<br/>Electron IPC"]):::bridge
|
|
||||||
|
|
||||||
subgraph Rend["Renderer — src/renderer/"]
|
|
||||||
OverlayWin["Main overlay window<br/>primary + secondary subtitles"]:::rend
|
|
||||||
UI["subtitle-render<br/>positioning<br/>handlers · modals"]:::rend
|
|
||||||
end
|
|
||||||
|
|
||||||
Launcher -->|"CLI"| Main
|
|
||||||
Plugin -->|"IPC"| mpvExt
|
|
||||||
|
|
||||||
Main --> Comp
|
|
||||||
Comp --> Svc
|
|
||||||
|
|
||||||
mpvExt <-->|"JSON socket"| Mpv
|
|
||||||
AnkiExt <-->|"HTTP"| AnkiProxy
|
|
||||||
JimakuExt <-->|"HTTP"| Integrations
|
|
||||||
TrackerExt <-->|"platform"| OverlaySvc
|
|
||||||
AnilistExt <-->|"HTTP"| Tracking
|
|
||||||
JellyfinExt <-->|"HTTP"| Tracking
|
|
||||||
DiscordExt <-->|"RPC"| Integrations
|
|
||||||
|
|
||||||
OverlaySvc & Mining --> Bridge
|
|
||||||
Bridge --> OverlayWin
|
|
||||||
OverlayWin --> UI
|
|
||||||
|
|
||||||
style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
style Rend fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
style Ext fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
style ExtRt fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Composition Pattern
|
|
||||||
|
|
||||||
Most runtime code follows a dependency-injection pattern:
|
|
||||||
|
|
||||||
1. Define a service interface in `src/core/services/*`.
|
|
||||||
2. Keep core logic in pure or side-effect-bounded functions.
|
|
||||||
3. Build runtime deps in `src/main/` composition modules; extract an adapter/helper only when it adds meaningful behavior or reuse.
|
|
||||||
4. Call the service from lifecycle/command wiring points.
|
|
||||||
|
|
||||||
The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`:
|
|
||||||
|
|
||||||
- `startup.ts` — argv/env processing and bootstrap flow
|
|
||||||
- `app-lifecycle.ts` — Electron lifecycle event registration
|
|
||||||
- `startup-lifecycle.ts` — app-ready initialization sequence
|
|
||||||
- `state.ts` — centralized application runtime state container
|
|
||||||
- `ipc-runtime.ts` — IPC channel registration and handler wiring
|
|
||||||
- `cli-runtime.ts` — CLI command parsing and dispatch
|
|
||||||
- `overlay-runtime.ts` — overlay window selection and modal state management
|
|
||||||
- `subsync-runtime.ts` — subsync command orchestration
|
|
||||||
- `runtime/composers/anilist-tracking-composer.ts` — AniList media tracking/probe/retry wiring
|
|
||||||
- `runtime/composers/jellyfin-runtime-composer.ts` — Jellyfin config/client/playback/command/setup composition wiring
|
|
||||||
- `runtime/composers/mpv-runtime-composer.ts` — MPV event/factory/tokenizer/warmup wiring
|
|
||||||
|
|
||||||
Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`:
|
|
||||||
|
|
||||||
- composer input surfaces are declared with `ComposerInputs<T>` so required dependencies cannot be omitted at compile time
|
|
||||||
- composer outputs are declared with `ComposerOutputs<T>` to keep result contracts explicit and stable
|
|
||||||
- builder return payload extraction should use shared type helpers instead of inline ad-hoc inference
|
|
||||||
|
|
||||||
This keeps side effects explicit and makes behavior easy to unit-test with fakes.
|
|
||||||
|
|
||||||
Additional conventions in the current code:
|
|
||||||
|
|
||||||
- `main.ts` uses `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`) to access domain handlers (`startup`, `overlay`, `mpv`, `ipc`, `shortcuts`, `anilist`, `jellyfin`, `mining`) without importing every runtime module directly.
|
|
||||||
- Domain barrels in `src/main/runtime/domains/*` re-export runtime handlers + main-deps builders, while composers in `src/main/runtime/composers/*` assemble larger runtime clusters.
|
|
||||||
- Many runtime handlers accept `*MainDeps` objects generated by `createBuild*MainDepsHandler` builders to isolate side effects and keep units testable.
|
|
||||||
|
|
||||||
### IPC Contract + Validation Boundary
|
|
||||||
|
|
||||||
- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring.
|
|
||||||
- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`.
|
|
||||||
- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers.
|
|
||||||
- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely.
|
|
||||||
|
|
||||||
### Runtime State Ownership (Migrated Domains)
|
|
||||||
|
|
||||||
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
|
|
||||||
|
|
||||||
- Composition/runtime modules own mutable state cells and expose narrow `get*`/`set*` accessors.
|
|
||||||
- Domain handlers do not mutate foreign state directly; they call explicit transition helpers that encode invariants.
|
|
||||||
- Transition helpers may sync derived counters/snapshots, but must preserve non-owned metadata unless the transition explicitly owns that metadata.
|
|
||||||
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
|
|
||||||
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
|
|
||||||
|
|
||||||
## Program Lifecycle
|
|
||||||
|
|
||||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
|
||||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
|
||||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
|
||||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
|
||||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
|
|
||||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through `SubtitlePipeline` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
|
||||||
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
classDef start fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold
|
|
||||||
classDef phase fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef init fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef runtime fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef shutdown fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
classDef warmup fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
|
||||||
|
|
||||||
CLI["CLI args &<br/>environment"]:::start
|
|
||||||
CLI --> Proto["Module-level init<br/>register protocols<br/>construct services<br/>wire deps"]:::phase
|
|
||||||
Proto --> Parse["startup.ts<br/>parse argv<br/>detect backend"]:::phase
|
|
||||||
Parse --> GenCheck{"--generate<br/>-config?"}:::decision
|
|
||||||
GenCheck -->|"yes"| GenExit["Write template<br/>& exit"]:::phase
|
|
||||||
GenCheck -->|"no"| Lock["app-lifecycle.ts<br/>single-instance lock<br/>lifecycle hooks"]:::phase
|
|
||||||
|
|
||||||
Lock -->|"app.whenReady()"| Ready["composeAppReady<br/>Runtime()"]:::phase
|
|
||||||
|
|
||||||
Ready --> Config["Config reload<br/>keybindings<br/>log level"]:::init
|
|
||||||
Ready --> MpvInit["MpvIpcClient<br/>connect socket<br/>subscribe 26 props"]:::init
|
|
||||||
Ready --> Platform["RuntimeOptions<br/>timing tracker<br/>immersion tracker"]:::init
|
|
||||||
|
|
||||||
Config --> OverlayInit
|
|
||||||
MpvInit --> OverlayInit
|
|
||||||
Platform --> OverlayInit
|
|
||||||
|
|
||||||
OverlayInit["initializeOverlay<br/>Runtime()"]:::phase
|
|
||||||
|
|
||||||
OverlayInit --> MainWin["Main overlay window<br/>primary + secondary subtitles"]:::init
|
|
||||||
OverlayInit --> Shortcuts["Register global<br/>shortcuts"]:::init
|
|
||||||
|
|
||||||
MainWin --> Warmups
|
|
||||||
Shortcuts --> Warmups
|
|
||||||
|
|
||||||
Warmups["Background<br/>warmups"]:::phase
|
|
||||||
|
|
||||||
subgraph WarmupGroup[" "]
|
|
||||||
direction TB
|
|
||||||
W1["MeCab<br/>+ worker thread"]:::warmup
|
|
||||||
W2["Yomitan"]:::warmup
|
|
||||||
W3["JLPT + freq<br/>dictionaries"]:::warmup
|
|
||||||
W4["Jellyfin"]:::warmup
|
|
||||||
W5["Discord"]:::warmup
|
|
||||||
W6["AniList"]:::warmup
|
|
||||||
W7["AnkiConnect<br/>proxy"]:::warmup
|
|
||||||
W1 ~~~ W2 ~~~ W3 ~~~ W4 ~~~ W5 ~~~ W6 ~~~ W7
|
|
||||||
end
|
|
||||||
|
|
||||||
Warmups --> WarmupGroup
|
|
||||||
|
|
||||||
subgraph Loop["Runtime — event-driven"]
|
|
||||||
direction TB
|
|
||||||
MpvEvt["mpv events: subtitle · timing · metrics"]:::runtime
|
|
||||||
IpcEvt["IPC: renderer requests · CLI commands"]:::runtime
|
|
||||||
ExtEvt["Shortcuts · config hot-reload"]:::runtime
|
|
||||||
MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime
|
|
||||||
Route --> Process["SubtitlePipeline<br/>normalize → tokenize → merge"]:::runtime
|
|
||||||
Process --> Broadcast["Update AppState<br/>broadcast to renderer + modals"]:::runtime
|
|
||||||
end
|
|
||||||
|
|
||||||
WarmupGroup --> Loop
|
|
||||||
|
|
||||||
style WarmupGroup fill:transparent,stroke:none
|
|
||||||
|
|
||||||
Loop -->|"quit signal"| Quit["will-quit"]:::shutdown
|
|
||||||
|
|
||||||
Quit --> T1["Tray · config watcher<br/>global shortcuts"]:::shutdown
|
|
||||||
Quit --> T2["WebSocket · texthooker<br/>mpv socket · OSD log"]:::shutdown
|
|
||||||
Quit --> T3["Window tracker<br/>Yomitan parser"]:::shutdown
|
|
||||||
Quit --> T4["Immersion tracker<br/>Jellyfin · Discord<br/>Anki proxy · AniList"]:::shutdown
|
|
||||||
|
|
||||||
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Why This Design
|
|
||||||
|
|
||||||
- **Smaller blast radius:** changing one feature usually touches one service.
|
|
||||||
- **Better testability:** most behavior can be tested without Electron windows/mpv.
|
|
||||||
- **Better reviewability:** PRs can be scoped to one subsystem.
|
|
||||||
- **Backward compatibility:** CLI flags and IPC channels can remain stable while internals evolve.
|
|
||||||
- **Runtime registry + domain barrels:** `src/main/runtime/registry.ts` and `src/main/runtime/domains/*` reduce direct fan-in inside `main.ts` while keeping domain ownership explicit.
|
|
||||||
- **Extracted composition root:** `main.ts` delegates to focused modules under `src/main/` and `src/main/runtime/composers/` for lifecycle, IPC, overlay, mpv, shortcut, and integration wiring.
|
|
||||||
- **Split MPV service layers:** MPV internals are separated into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and properties/render metrics modules for maintainability.
|
|
||||||
- **Config by domain:** defaults, option registries, and resolution are split by domain under `src/config/definitions/*` and `src/config/resolve/*`, keeping config evolution localized.
|
|
||||||
|
|
||||||
## Extension Rules
|
|
||||||
|
|
||||||
- Add behavior to an existing service in `src/core/services/*` or create a focused runtime module under `src/main/runtime/*`; avoid ad-hoc logic in `main.ts`.
|
|
||||||
- Add new cross-process channels in `src/shared/ipc/contracts.ts` first, validate payloads in `src/shared/ipc/validators.ts`, then wire handlers in IPC runtime modules.
|
|
||||||
- See also the contributor IPC onboarding page: [IPC + Runtime Contracts](/ipc-contracts).
|
|
||||||
- If change spans startup/overlay/mpv/integration wiring, prefer composing through `src/main/runtime/domains/*` + `src/main/runtime/composers/*` rather than direct wiring in `main.ts`.
|
|
||||||
- Keep service APIs explicit and narrowly scoped, and preserve existing CLI flag / IPC channel behavior unless the change is intentionally breaking.
|
|
||||||
- Add or update focused tests (including malformed-payload IPC tests) when runtime boundaries or contracts change.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Feature Demos
|
|
||||||
|
|
||||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const v = '20260301-1';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
## Anki Card Mining & Enrichment
|
|
||||||
|
|
||||||
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
|
||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
|
||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
|
||||||
</a>
|
|
||||||
</video>
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Subtitle Download & Sync
|
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Jellyfin Integration
|
|
||||||
|
|
||||||
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Texthooker
|
|
||||||
|
|
||||||
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
|
||||||
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
|
||||||
</video> -->
|
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
|
||||||
:::
|
|
||||||
|
|
||||||
<style>
|
|
||||||
video {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--vp-c-divider);
|
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
|
||||||
margin: 0.75rem 0 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-top: 2.5rem !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
# Development
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- [Bun](https://bun.sh)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
|
||||||
cd SubMiner
|
|
||||||
# if you cloned without --recurse-submodules:
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
make deps
|
|
||||||
# or manually:
|
|
||||||
bun install
|
|
||||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# TypeScript compile (fast, for development)
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Generate launcher wrapper artifact
|
|
||||||
make build-launcher
|
|
||||||
# output: dist/launcher/subminer
|
|
||||||
|
|
||||||
# Full platform build (includes texthooker-ui + AppImage/DMG)
|
|
||||||
make build
|
|
||||||
|
|
||||||
# Platform-specific builds
|
|
||||||
make build-linux # Linux AppImage
|
|
||||||
make build-macos # macOS DMG + ZIP (signed)
|
|
||||||
make build-macos-unsigned # macOS DMG + ZIP (unsigned)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Launcher Artifact Workflow
|
|
||||||
|
|
||||||
- Source of truth: `launcher/*.ts`
|
|
||||||
- Generated output: `dist/launcher/subminer`
|
|
||||||
- Do not hand-edit generated launcher output.
|
|
||||||
- Repo-root `./subminer` is a stale artifact path and is rejected by verification checks.
|
|
||||||
- Install targets (`make install-linux`, `make install-macos`) copy from `dist/launcher/subminer`.
|
|
||||||
|
|
||||||
Verify the workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make build-launcher
|
|
||||||
dist/launcher/subminer --help >/dev/null
|
|
||||||
bash scripts/verify-generated-launcher.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev # builds + launches with --start --dev
|
|
||||||
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
|
|
||||||
electron . --background # tray/background mode, minimal default logging
|
|
||||||
make dev-start # build + launch via Makefile
|
|
||||||
make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
|
|
||||||
make dev-watch-macos # same as dev-watch, forcing --backend macos
|
|
||||||
```
|
|
||||||
|
|
||||||
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
|
|
||||||
dev binary path in `~/.config/mpv/script-opts/subminer.conf`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
binary_path=/absolute/path/to/SubMiner/scripts/subminer-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
CI-equivalent local gate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run tsc --noEmit
|
|
||||||
bun run test:fast
|
|
||||||
bun run test:launcher:smoke:src
|
|
||||||
bun run build
|
|
||||||
bun run test:smoke:dist
|
|
||||||
bun run docs:build
|
|
||||||
```
|
|
||||||
|
|
||||||
Common focused commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run test:config # Source-level config schema/validation tests
|
|
||||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
|
||||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
|
||||||
bun run test:core # Source-level core regression tests (default lane)
|
|
||||||
bun run test:fast # Source-level config + core lane (no build prerequisite)
|
|
||||||
```
|
|
||||||
|
|
||||||
Dist-level tests are now an explicit smoke lane used to validate compiled/runtime assumptions.
|
|
||||||
|
|
||||||
Launcher smoke artifacts are written to `.tmp/launcher-smoke` locally and uploaded by CI/release workflows when the smoke step fails.
|
|
||||||
|
|
||||||
Smoke and optional deep dist commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build # compile dist artifacts
|
|
||||||
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
|
||||||
bun run test:config:dist # optional full dist config suite
|
|
||||||
bun run test:core:dist # optional full dist core suite
|
|
||||||
```
|
|
||||||
|
|
||||||
`bun run test:subtitle` and `bun run test:subtitle:dist` are currently placeholders and do not run an active suite.
|
|
||||||
|
|
||||||
## Config Generation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate default config to ~/.config/SubMiner/config.jsonc
|
|
||||||
make generate-config
|
|
||||||
|
|
||||||
# Regenerate the repo's config.example.jsonc from centralized defaults
|
|
||||||
make generate-example-config
|
|
||||||
# or: bun run generate:config-example
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation Site
|
|
||||||
|
|
||||||
The docs use [VitePress](https://vitepress.dev/):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make docs-dev # Dev server at http://localhost:5173
|
|
||||||
make docs # Build static output
|
|
||||||
make docs-preview # Preview built site at http://localhost:4173
|
|
||||||
```
|
|
||||||
|
|
||||||
## Makefile Reference
|
|
||||||
|
|
||||||
Run `make help` for a full list of targets. Key ones:
|
|
||||||
|
|
||||||
| Target | Description |
|
|
||||||
| ---------------------- | ---------------------------------------------------------------- |
|
|
||||||
| `make build` | Build platform package for detected OS |
|
|
||||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
|
||||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
|
||||||
| `make install-plugin` | Install mpv Lua plugin and config |
|
|
||||||
| `make deps` | Install JS dependencies (root + texthooker-ui) |
|
|
||||||
| `make generate-config` | Generate default config from centralized registry |
|
|
||||||
| `make docs-dev` | Run VitePress dev server |
|
|
||||||
|
|
||||||
## Contributor Notes
|
|
||||||
|
|
||||||
- To add/change a config default, edit the matching domain file in `src/config/definitions/defaults-*.ts`.
|
|
||||||
- To add/change config option metadata, edit the matching domain file in `src/config/definitions/options-*.ts`.
|
|
||||||
- To add/change generated config template blocks/comments, update `src/config/definitions/template-sections.ts`.
|
|
||||||
- Keep `src/config/definitions.ts` as the composed public API (`DEFAULT_CONFIG`, registries, template export) that wires domain modules together.
|
|
||||||
- Overlay window/visibility state is owned by `src/core/services/overlay-manager.ts`.
|
|
||||||
- Runtime architecture/module-boundary conventions are documented in [Architecture](/architecture); keep contributor changes aligned with that canonical guide.
|
|
||||||
- Linux packaged desktop launches pass `--background` using electron-builder `build.linux.executableArgs` in `package.json`.
|
|
||||||
- Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring.
|
|
||||||
- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping).
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
|
||||||
| `SUBMINER_APPIMAGE_PATH` | Override SubMiner app binary path for launcher playback commands |
|
|
||||||
| `SUBMINER_BINARY_PATH` | Alias for `SUBMINER_APPIMAGE_PATH` |
|
|
||||||
| `SUBMINER_ROFI_THEME` | Override rofi theme path for launcher picker |
|
|
||||||
| `SUBMINER_LOG_LEVEL` | Override app logger level (`debug`, `info`, `warn`, `error`) |
|
|
||||||
| `SUBMINER_MPV_LOG` | Override mpv/app shared log file path |
|
|
||||||
| `SUBMINER_YT_SUBGEN_MODE` | Override `youtubeSubgen.mode` for launcher |
|
|
||||||
| `SUBMINER_WHISPER_BIN` | Override `youtubeSubgen.whisperBin` for launcher |
|
|
||||||
| `SUBMINER_WHISPER_MODEL` | Override `youtubeSubgen.whisperModel` for launcher |
|
|
||||||
| `SUBMINER_YT_SUBGEN_OUT_DIR` | Override generated subtitle output directory |
|
|
||||||
| `SUBMINER_YT_SUBGEN_AUDIO_FORMAT` | Override extraction format used for whisper fallback |
|
|
||||||
| `SUBMINER_YT_SUBGEN_KEEP_TEMP` | Set to `1` to keep temporary subtitle-generation workspace |
|
|
||||||
| `SUBMINER_JIMAKU_API_KEY` | Override Jimaku API key for launcher subtitle downloads |
|
|
||||||
| `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command used to resolve Jimaku API key at runtime |
|
|
||||||
| `SUBMINER_JIMAKU_API_BASE_URL` | Override Jimaku API base URL |
|
|
||||||
| `SUBMINER_JELLYFIN_ACCESS_TOKEN` | Override Jellyfin access token (used before stored encrypted session fallback) |
|
|
||||||
| `SUBMINER_JELLYFIN_USER_ID` | Optional Jellyfin user ID override |
|
|
||||||
| `SUBMINER_SKIP_MACOS_HELPER_BUILD` | Set to `1` to skip building the macOS helper binary during `bun run build` |
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Immersion Tracking Storage
|
|
||||||
|
|
||||||
SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by default.
|
|
||||||
|
|
||||||
## Runtime Model
|
|
||||||
|
|
||||||
- Write path is asynchronous and queue-backed.
|
|
||||||
- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes.
|
|
||||||
- Background line processing also upserts to `imm_words` and `imm_kanji`.
|
|
||||||
- Queue overflow policy is deterministic: drop oldest queued writes, keep newest.
|
|
||||||
- Flush policy defaults to `25` writes or `500ms` max delay.
|
|
||||||
- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`.
|
|
||||||
- Rollups now run incrementally from the last processed telemetry sample; startup performs a one-time bootstrap rebuild-equivalent pass.
|
|
||||||
- If retention pruning removes telemetry/session rows, maintenance triggers a full rollup rebuild to resync historical aggregates.
|
|
||||||
|
|
||||||
## Schema (v3)
|
|
||||||
|
|
||||||
Schema versioning table:
|
|
||||||
|
|
||||||
- `imm_schema_version(schema_version PK, applied_at_ms)`
|
|
||||||
|
|
||||||
Core entities:
|
|
||||||
|
|
||||||
- `imm_videos`: video key/title/source metadata + optional media metadata fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
- `imm_sessions`: session UUID, video reference, timing/status fields, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
- `imm_session_telemetry`: high-frequency session aggregates over time, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
- `imm_session_events`: event stream with compact numeric event types, `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
|
|
||||||
Rollups:
|
|
||||||
|
|
||||||
- `imm_daily_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
- `imm_monthly_rollups`: includes `CREATED_DATE`/`LAST_UPDATE_DATE`
|
|
||||||
|
|
||||||
Vocabulary:
|
|
||||||
|
|
||||||
- `imm_words(id, headword, word, reading, first_seen, last_seen, frequency)`
|
|
||||||
- `imm_kanji(id, kanji, first_seen, last_seen, frequency)`
|
|
||||||
- `first_seen`/`last_seen` store Unix timestamps and are upserted with line ingestion
|
|
||||||
|
|
||||||
Primary index coverage:
|
|
||||||
|
|
||||||
- session-by-video/time: `idx_sessions_video_started`
|
|
||||||
- session-by-status/time: `idx_sessions_status_started`
|
|
||||||
- timeline reads: `idx_telemetry_session_sample`
|
|
||||||
- event timeline/type reads: `idx_events_session_ts`, `idx_events_type_ts`
|
|
||||||
- rollup reads: `idx_rollups_day_video`, `idx_rollups_month_video`
|
|
||||||
|
|
||||||
## Retention and Maintenance Defaults
|
|
||||||
|
|
||||||
- Raw events: `7d`
|
|
||||||
- Telemetry: `30d`
|
|
||||||
- Daily rollups: `365d`
|
|
||||||
- Monthly rollups: `5y`
|
|
||||||
- Maintenance cadence: startup + every `24h`
|
|
||||||
- Vacuum cadence: idle weekly (`7d` minimum spacing)
|
|
||||||
|
|
||||||
Retention cleanup and rollup refresh stay in service maintenance orchestration + `src/core/services/immersion-tracker/maintenance.ts`.
|
|
||||||
|
|
||||||
## Configurable Policy Knobs
|
|
||||||
|
|
||||||
All knobs are under `immersionTracking` in config:
|
|
||||||
|
|
||||||
- `batchSize`
|
|
||||||
- `flushIntervalMs`
|
|
||||||
- `queueCap`
|
|
||||||
- `payloadCapBytes`
|
|
||||||
- `maintenanceIntervalMs`
|
|
||||||
- `retention.eventsDays`
|
|
||||||
- `retention.telemetryDays`
|
|
||||||
- `retention.dailyRollupsDays`
|
|
||||||
- `retention.monthlyRollupsDays`
|
|
||||||
- `retention.vacuumIntervalDays`
|
|
||||||
|
|
||||||
These map directly to runtime tracker policy and allow tuning without code changes.
|
|
||||||
|
|
||||||
## Query Templates
|
|
||||||
|
|
||||||
Timeline for one session:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
sample_ms,
|
|
||||||
total_watched_ms,
|
|
||||||
active_watched_ms,
|
|
||||||
lines_seen,
|
|
||||||
words_seen,
|
|
||||||
tokens_seen,
|
|
||||||
cards_mined
|
|
||||||
FROM imm_session_telemetry
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY sample_ms DESC
|
|
||||||
LIMIT ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
Session throughput summary:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
s.session_id,
|
|
||||||
s.video_id,
|
|
||||||
s.started_at_ms,
|
|
||||||
s.ended_at_ms,
|
|
||||||
COALESCE(SUM(t.active_watched_ms), 0) AS active_watched_ms,
|
|
||||||
COALESCE(SUM(t.words_seen), 0) AS words_seen,
|
|
||||||
COALESCE(SUM(t.cards_mined), 0) AS cards_mined,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
|
|
||||||
THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
|
|
||||||
ELSE NULL
|
|
||||||
END AS words_per_min,
|
|
||||||
CASE
|
|
||||||
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
|
|
||||||
THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
|
|
||||||
ELSE NULL
|
|
||||||
END AS cards_per_hour
|
|
||||||
FROM imm_sessions s
|
|
||||||
LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id
|
|
||||||
GROUP BY s.session_id
|
|
||||||
ORDER BY s.started_at_ms DESC
|
|
||||||
LIMIT ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
Daily rollups:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
rollup_day,
|
|
||||||
video_id,
|
|
||||||
total_sessions,
|
|
||||||
total_active_min,
|
|
||||||
total_lines_seen,
|
|
||||||
total_words_seen,
|
|
||||||
total_tokens_seen,
|
|
||||||
total_cards,
|
|
||||||
cards_per_hour,
|
|
||||||
words_per_min,
|
|
||||||
lookup_hit_rate
|
|
||||||
FROM imm_daily_rollups
|
|
||||||
ORDER BY rollup_day DESC, video_id DESC
|
|
||||||
LIMIT ?;
|
|
||||||
```
|
|
||||||
|
|
||||||
Monthly rollups:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
rollup_month,
|
|
||||||
video_id,
|
|
||||||
total_sessions,
|
|
||||||
total_active_min,
|
|
||||||
total_lines_seen,
|
|
||||||
total_words_seen,
|
|
||||||
total_tokens_seen,
|
|
||||||
total_cards
|
|
||||||
FROM imm_monthly_rollups
|
|
||||||
ORDER BY rollup_month DESC, video_id DESC
|
|
||||||
LIMIT ?;
|
|
||||||
```
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { expect, test } from 'bun:test';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
const docsIndexPath = new URL('./index.md', import.meta.url);
|
|
||||||
const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
|
||||||
|
|
||||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
|
||||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
|
|
||||||
);
|
|
||||||
expect(docsIndexContents).toContain(
|
|
||||||
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
225
docs/index.md
@@ -1,225 +0,0 @@
|
|||||||
---
|
|
||||||
layout: home
|
|
||||||
|
|
||||||
title: SubMiner
|
|
||||||
titleTemplate: Immersion Mining Workflow for MPV
|
|
||||||
|
|
||||||
hero:
|
|
||||||
name: SubMiner
|
|
||||||
text: Immersion Mining for MPV
|
|
||||||
tagline: Watch media, mine vocabulary, and craft anki cards without leaving the scene.
|
|
||||||
image:
|
|
||||||
src: /assets/SubMiner.png
|
|
||||||
alt: SubMiner logo
|
|
||||||
actions:
|
|
||||||
- theme: brand
|
|
||||||
text: Install
|
|
||||||
link: /installation
|
|
||||||
- theme: alt
|
|
||||||
text: Explore workflow
|
|
||||||
link: /mining-workflow
|
|
||||||
|
|
||||||
features:
|
|
||||||
- icon:
|
|
||||||
src: /assets/mpv.svg
|
|
||||||
alt: mpv icon
|
|
||||||
title: Built for mpv
|
|
||||||
details: Tracks subtitles through mpv IPC in real time, with a single launch path and no external bridge services.
|
|
||||||
- icon:
|
|
||||||
src: /assets/yomitan-icon.svg
|
|
||||||
alt: Yomitan logo
|
|
||||||
title: Yomitan Integration
|
|
||||||
details: Keep your flow moving with instant word lookups and context-aware card creation directly from subtitles.
|
|
||||||
- icon:
|
|
||||||
src: /assets/anki-card.svg
|
|
||||||
alt: Anki card icon
|
|
||||||
title: Anki Card Enrichment
|
|
||||||
details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning.
|
|
||||||
- icon:
|
|
||||||
src: /assets/highlight.svg
|
|
||||||
alt: Highlight icon
|
|
||||||
title: Reading Annotations
|
|
||||||
details: Combines N+1 targeting, Jiten frequency highlighting, and JLPT tagging so useful cues stay visible while you read.
|
|
||||||
- icon:
|
|
||||||
src: /assets/tokenization.svg
|
|
||||||
alt: Tokenization icon
|
|
||||||
title: Immersion Tracking
|
|
||||||
details: Captures subtitle and mining telemetry to SQLite, with daily/monthly rollups for progress clarity.
|
|
||||||
- icon:
|
|
||||||
src: /assets/subtitle-download.svg
|
|
||||||
alt: Subtitle download icon
|
|
||||||
title: Subtitle Download & Sync
|
|
||||||
details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow.
|
|
||||||
---
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const demoAssetVersion = '20260223-2';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="landing-shell">
|
|
||||||
<section class="workflow-section">
|
|
||||||
<h2>How it fits together</h2>
|
|
||||||
<div class="workflow-steps">
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-number">01</div>
|
|
||||||
<div class="step-title">Start</div>
|
|
||||||
<div class="step-desc">Launch with the wrapper or existing mpv setup and keep subtitles in sync.</div>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-number">02</div>
|
|
||||||
<div class="step-title">Lookup</div>
|
|
||||||
<div class="step-desc">Hover or click a token in the interactive overlay to open Yomitan context.</div>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-number">03</div>
|
|
||||||
<div class="step-title">Mine</div>
|
|
||||||
<div class="step-desc">Create cards from Yomitan or mine sentence cards directly from subtitle lines.</div>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-number">04</div>
|
|
||||||
<div class="step-title">Enrich</div>
|
|
||||||
<div class="step-desc">Automatically attach timing-accurate audio, sentence text, and visual evidence.</div>
|
|
||||||
</div>
|
|
||||||
<div class="workflow-step">
|
|
||||||
<div class="step-number">05</div>
|
|
||||||
<div class="step-title">Track</div>
|
|
||||||
<div class="step-desc">Review immersion history and repeat high-value patterns over time.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="demo-section">
|
|
||||||
<h2>See it in action</h2>
|
|
||||||
<p>Subtitles, lookup flow, and card enrichment from a real playback session.</p>
|
|
||||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
|
||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
|
||||||
</a>
|
|
||||||
</video>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap');
|
|
||||||
|
|
||||||
.landing-shell {
|
|
||||||
max-width: 1120px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0.5rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.landing-shell,
|
|
||||||
.landing-shell .step-title,
|
|
||||||
.landing-shell h1,
|
|
||||||
.landing-shell h2 {
|
|
||||||
font-family: 'Manrope', 'Arial', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-title,
|
|
||||||
.step-number {
|
|
||||||
font-family: 'Space Grotesk', 'Manrope', 'Arial', sans-serif;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section h2 {
|
|
||||||
font-size: 1.45rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section p {
|
|
||||||
color: var(--vp-c-text-2);
|
|
||||||
margin: 0 0 0.9rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-section video {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--vp-c-divider);
|
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
|
||||||
animation: card-enter 380ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-section {
|
|
||||||
margin: 2.4rem auto 0;
|
|
||||||
padding: 0 0 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-section h2 {
|
|
||||||
font-size: 1.45rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-steps {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--vp-c-divider);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.workflow-steps {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.workflow-steps {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step {
|
|
||||||
padding: 1.1rem 1.25rem;
|
|
||||||
background: var(--vp-c-bg-soft);
|
|
||||||
animation: card-enter 330ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step .step-number {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--vp-c-brand-1);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step .step-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-step .step-desc {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--vp-c-text-2);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes card-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0.8;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# Installation
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### System Dependencies
|
|
||||||
|
|
||||||
| Dependency | Required | Notes |
|
|
||||||
| -------------------- | ---------- | -------------------------------------------------------- |
|
|
||||||
| Bun | Yes | Required for `subminer` wrapper and source workflows |
|
|
||||||
| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) |
|
|
||||||
| ffmpeg | For media | Audio extraction and screenshot generation |
|
|
||||||
| MeCab + mecab-ipadic | No | Optional fallback tokenizer for Japanese |
|
|
||||||
| fuse2 | Linux only | Required for AppImage |
|
|
||||||
| yt-dlp | No | Recommended for YouTube playback and subtitle extraction |
|
|
||||||
|
|
||||||
### Platform-Specific
|
|
||||||
|
|
||||||
**Linux** — one of the following compositors:
|
|
||||||
|
|
||||||
- Hyprland (uses `hyprctl`)
|
|
||||||
- Sway (uses `swaymsg`)
|
|
||||||
- X11 (uses `xdotool` and `xwininfo`)
|
|
||||||
|
|
||||||
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
|
||||||
|
|
||||||
### Optional Tools
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
|
||||||
| ----------------- | ------------------------------------------------------------- |
|
|
||||||
| fzf | Terminal-based video picker (default) |
|
|
||||||
| rofi | GUI-based video picker |
|
|
||||||
| chafa | Thumbnail previews in fzf |
|
|
||||||
| ffmpegthumbnailer | Generate video thumbnails for picker |
|
|
||||||
| guessit | Better AniSkip title/season/episode parsing for file playback |
|
|
||||||
| alass | Subtitle sync engine (preferred) |
|
|
||||||
| ffsubsync | Subtitle sync engine (fallback) |
|
|
||||||
|
|
||||||
## Linux
|
|
||||||
|
|
||||||
### AppImage (Recommended)
|
|
||||||
|
|
||||||
Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download and install AppImage
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
|
|
||||||
chmod +x ~/.local/bin/SubMiner.AppImage
|
|
||||||
|
|
||||||
# Download subminer wrapper script
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
|
||||||
chmod +x ~/.local/bin/subminer
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
The `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`.
|
|
||||||
|
|
||||||
### From Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ksyasuda/SubMiner.git
|
|
||||||
cd SubMiner
|
|
||||||
make build
|
|
||||||
make build-launcher
|
|
||||||
|
|
||||||
# Install platform artifacts (wrapper + theme + AppImage)
|
|
||||||
make install
|
|
||||||
```
|
|
||||||
|
|
||||||
`make build-launcher` generates the wrapper at `dist/launcher/subminer`. The checked-in launcher source remains `launcher/*.ts`.
|
|
||||||
Do not use a repo-root `./subminer` artifact when building from source; workflow checks enforce `dist/launcher/subminer` as the only generated path.
|
|
||||||
|
|
||||||
## macOS
|
|
||||||
|
|
||||||
### DMG (Recommended)
|
|
||||||
|
|
||||||
Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`.
|
|
||||||
|
|
||||||
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
|
|
||||||
|
|
||||||
Install dependencies using Homebrew:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install mpv mecab mecab-ipadic
|
|
||||||
```
|
|
||||||
|
|
||||||
### From Source (macOS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ksyasuda/SubMiner.git
|
|
||||||
cd SubMiner
|
|
||||||
bun install
|
|
||||||
cd vendor/texthooker-ui && bun install --frozen-lockfile && bun run build && cd ../..
|
|
||||||
bun run build:mac
|
|
||||||
```
|
|
||||||
|
|
||||||
The built app will be available in the `release` directory (`.dmg` and `.zip`).
|
|
||||||
|
|
||||||
For unsigned local builds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build:mac:unsigned
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessibility Permission
|
|
||||||
|
|
||||||
After launching SubMiner for the first time, grant accessibility permission:
|
|
||||||
|
|
||||||
1. Open **System Preferences** → **Security & Privacy** → **Privacy** tab
|
|
||||||
2. Select **Accessibility** from the left sidebar
|
|
||||||
3. Add SubMiner to the list
|
|
||||||
|
|
||||||
Without this permission, window tracking will not work and the overlay won't follow the mpv window.
|
|
||||||
|
|
||||||
### macOS Usage Notes
|
|
||||||
|
|
||||||
**Launching MPV with IPC:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mpv --input-ipc-server=/tmp/subminer-socket video.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`).
|
|
||||||
|
|
||||||
**MeCab paths (Homebrew):**
|
|
||||||
|
|
||||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
|
||||||
- Intel: `/usr/local/bin/mecab`
|
|
||||||
|
|
||||||
Ensure `mecab` is available on your PATH when launching SubMiner.
|
|
||||||
|
|
||||||
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
|
|
||||||
|
|
||||||
**mpv plugin binary path:**
|
|
||||||
|
|
||||||
```ini
|
|
||||||
binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
|
|
||||||
```
|
|
||||||
|
|
||||||
## MPV Plugin (Recommended)
|
|
||||||
|
|
||||||
The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags.
|
|
||||||
|
|
||||||
::: warning Important
|
|
||||||
mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect.
|
|
||||||
:::
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Option 1: install from release assets bundle
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
|
||||||
mkdir -p ~/.config/SubMiner
|
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
|
||||||
mkdir -p ~/.config/mpv/scripts/subminer
|
|
||||||
mkdir -p ~/.config/mpv/script-opts
|
|
||||||
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
|
||||||
|
|
||||||
# Option 2: from source checkout
|
|
||||||
# make install-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rofi Theme (Linux Only)
|
|
||||||
|
|
||||||
SubMiner ships a default rofi theme at `assets/themes/subminer.rasi`.
|
|
||||||
|
|
||||||
Install path (default auto-detected by `subminer`):
|
|
||||||
|
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
|
||||||
```
|
|
||||||
|
|
||||||
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
|
||||||
|
|
||||||
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|
||||||
|
|
||||||
| Chord | Action |
|
|
||||||
| ----- | ------------------------------------- |
|
|
||||||
| `y-y` | Open SubMiner menu (fuzzy-searchable) |
|
|
||||||
| `y-s` | Start overlay |
|
|
||||||
| `y-S` | Stop overlay |
|
|
||||||
| `y-t` | Toggle visible overlay |
|
|
||||||
| `y-o` | Open Yomitan settings |
|
|
||||||
| `y-r` | Restart overlay |
|
|
||||||
| `y-c` | Check overlay status |
|
|
||||||
|
|
||||||
See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messages, and binary auto-detection details.
|
|
||||||
|
|
||||||
## Verify Installation
|
|
||||||
|
|
||||||
After installing, confirm SubMiner is working:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Play a file (default plugin config auto-starts visible overlay and waits for annotation readiness)
|
|
||||||
subminer video.mkv
|
|
||||||
|
|
||||||
# Optional explicit overlay start for setups with plugin auto_start=no
|
|
||||||
subminer --start video.mkv
|
|
||||||
|
|
||||||
# Useful launch modes for troubleshooting
|
|
||||||
subminer --log-level debug video.mkv
|
|
||||||
SubMiner.AppImage --start --log-level debug
|
|
||||||
|
|
||||||
# Or with direct AppImage control
|
|
||||||
SubMiner.AppImage --background # Background tray service mode
|
|
||||||
SubMiner.AppImage --start
|
|
||||||
SubMiner.AppImage --start --dev
|
|
||||||
SubMiner.AppImage --help # Show all CLI options
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay.
|
|
||||||
|
|
||||||
Next: [Usage](/usage) — learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# IPC + Runtime Contracts
|
|
||||||
|
|
||||||
## Core Surfaces
|
|
||||||
|
|
||||||
- `src/shared/ipc/contracts.ts`: canonical channel names and payload contracts
|
|
||||||
- `src/shared/ipc/validators.ts`: runtime payload validation/parsing
|
|
||||||
- `src/preload.ts`: renderer bridge to approved IPC endpoints
|
|
||||||
- `src/main/ipc-runtime.ts`: main-process registration and handler routing
|
|
||||||
- `src/core/services/ipc.ts`: service-level invoke handling and guardrails
|
|
||||||
- `src/core/services/anki-jimaku-ipc.ts`: integration-specific IPC boundary
|
|
||||||
- `src/main/cli-runtime.ts`: CLI/runtime command boundary
|
|
||||||
|
|
||||||
## Contract Rules
|
|
||||||
|
|
||||||
- Use shared contract constants; avoid ad-hoc literal channel strings.
|
|
||||||
- Validate `invoke` payloads before domain/service logic.
|
|
||||||
- Return structured failures (`{ ok: false, error }`) where possible.
|
|
||||||
- Keep payloads narrow and explicit.
|
|
||||||
- Update contracts, validators, preload types, and handlers in the same change when shape changes.
|
|
||||||
|
|
||||||
## Add a New IPC Action
|
|
||||||
|
|
||||||
1. Add channel in `src/shared/ipc/contracts.ts`.
|
|
||||||
2. Add or extend validator in `src/shared/ipc/validators.ts`.
|
|
||||||
3. Expose typed bridge method in `src/preload.ts`.
|
|
||||||
4. Register handler in `src/main/ipc-runtime.ts` (or relevant domain runtime module).
|
|
||||||
5. Add tests for valid and malformed payload cases in `src/core/services/*`.
|
|
||||||
6. Update renderer tests when behavior or state transitions change.
|
|
||||||
|
|
||||||
## Runtime State Notes
|
|
||||||
|
|
||||||
- Prefer runtime/domain composition via `src/main/runtime/composers/*` and `src/main/runtime/domains/*`.
|
|
||||||
- Route shared mutable state updates through transition helpers in `src/main/state.ts` for migrated domains.
|
|
||||||
- Keep IPC handlers thin; avoid embedding business logic in transport wiring.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- Unknown payload in handler: confirm validator is applied before handler/service call.
|
|
||||||
- Renderer invoke fails: verify preload bridge method and channel registration.
|
|
||||||
- Contract drift: compare shared contract, validator, preload bridge, and main handler signatures together.
|
|
||||||
|
|
||||||
## Related Docs
|
|
||||||
|
|
||||||
- [Architecture](/architecture)
|
|
||||||
- [Development](/development)
|
|
||||||
- [Configuration](/configuration)
|
|
||||||
- [Troubleshooting](/troubleshooting)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Jellyfin Integration
|
|
||||||
|
|
||||||
SubMiner includes an optional Jellyfin CLI integration for:
|
|
||||||
|
|
||||||
- authenticating against a server
|
|
||||||
- listing libraries and media items
|
|
||||||
- launching item playback in the connected mpv instance
|
|
||||||
- receiving Jellyfin remote cast-to-device playback events in-app
|
|
||||||
- opening an in-app setup window for server/user/password input
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Jellyfin server URL and user credentials
|
|
||||||
- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow)
|
|
||||||
- On Linux, token encryption defaults to `gnome-libsecret`; pass `--password-store=<backend>` to override.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Set base config values (`config.jsonc`):
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"jellyfin": {
|
|
||||||
"enabled": true,
|
|
||||||
"serverUrl": "http://127.0.0.1:8096",
|
|
||||||
"username": "your-user",
|
|
||||||
"remoteControlEnabled": true,
|
|
||||||
"remoteControlAutoConnect": true,
|
|
||||||
"autoAnnounce": false,
|
|
||||||
"remoteControlDeviceName": "SubMiner",
|
|
||||||
"defaultLibraryId": "",
|
|
||||||
"pullPictures": false,
|
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons",
|
|
||||||
"directPlayPreferred": true,
|
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
|
|
||||||
"transcodeVideoCodec": "h264",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Authenticate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer jellyfin
|
|
||||||
subminer jellyfin -l \
|
|
||||||
--server http://127.0.0.1:8096 \
|
|
||||||
--username your-user \
|
|
||||||
--password 'your-password'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. List libraries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --jellyfin-libraries
|
|
||||||
```
|
|
||||||
|
|
||||||
Launcher wrapper equivalent for interactive playback flow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer jellyfin -p
|
|
||||||
```
|
|
||||||
|
|
||||||
Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer jellyfin -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Stop discovery session/app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer app --stop
|
|
||||||
```
|
|
||||||
|
|
||||||
`subminer jf ...` is an alias for `subminer jellyfin ...`.
|
|
||||||
|
|
||||||
To clear saved session credentials:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer jellyfin --logout
|
|
||||||
```
|
|
||||||
|
|
||||||
4. List items in a library:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional listing controls:
|
|
||||||
|
|
||||||
- `--jellyfin-recursive=true|false` (default: true)
|
|
||||||
- `--jellyfin-include-item-types=Series,Season,Folder,CollectionFolder,Movie,...`
|
|
||||||
|
|
||||||
These are used by the launcher picker flow to:
|
|
||||||
|
|
||||||
- keep root search focused on shows/folders/movies (exclude episode rows)
|
|
||||||
- browse selected anime/show directories as folder-or-file lists
|
|
||||||
- recurse for playable files only after selecting a folder
|
|
||||||
|
|
||||||
5. Start playback:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --start
|
|
||||||
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional stream overrides:
|
|
||||||
|
|
||||||
- `--jellyfin-audio-stream-index N`
|
|
||||||
- `--jellyfin-subtitle-stream-index N`
|
|
||||||
|
|
||||||
## Playback Behavior
|
|
||||||
|
|
||||||
- Direct play is attempted first when:
|
|
||||||
- `jellyfin.directPlayPreferred=true`
|
|
||||||
- media source supports direct stream
|
|
||||||
- source container matches `jellyfin.directPlayContainers`
|
|
||||||
- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`.
|
|
||||||
- Resume position (`PlaybackPositionTicks`) is applied via mpv seek.
|
|
||||||
- Media title is set in mpv as `[Jellyfin/<mode>] <title>`.
|
|
||||||
|
|
||||||
## Cast To Device Mode (jellyfin-mpv-shim style)
|
|
||||||
|
|
||||||
When SubMiner is running with a valid Jellyfin session, it can appear as a
|
|
||||||
remote playback target in Jellyfin's cast-to-device menu.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- `jellyfin.enabled=true`
|
|
||||||
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
|
|
||||||
- `jellyfin.remoteControlEnabled=true` (default)
|
|
||||||
- `jellyfin.remoteControlAutoConnect=true` (default)
|
|
||||||
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
|
|
||||||
|
|
||||||
### Behavior
|
|
||||||
|
|
||||||
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
|
|
||||||
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
|
|
||||||
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
|
|
||||||
- `Playstate` events map to mpv pause/resume/seek/stop controls.
|
|
||||||
- Stream selection commands (`SetAudioStreamIndex`, `SetSubtitleStreamIndex`) are mapped to mpv track selection.
|
|
||||||
- SubMiner reports start/progress/stop timeline updates back to Jellyfin so now-playing and resume state stay synchronized.
|
|
||||||
- `--jellyfin-remote-announce` forces an immediate capability re-broadcast and logs whether server sessions can see the device.
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
- Device not visible in Jellyfin cast menu:
|
|
||||||
- ensure SubMiner is running
|
|
||||||
- ensure session token is valid (`--jellyfin-login` again if needed)
|
|
||||||
- ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true
|
|
||||||
- Cast command received but playback does not start:
|
|
||||||
- verify mpv IPC can connect (`--start` flow)
|
|
||||||
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
|
|
||||||
- Frequent reconnects:
|
|
||||||
- check Jellyfin server/network stability and token expiration
|
|
||||||
|
|
||||||
## Failure Handling
|
|
||||||
|
|
||||||
User-visible errors are shown through CLI logs and mpv OSD for:
|
|
||||||
|
|
||||||
- invalid credentials
|
|
||||||
- expired/invalid token
|
|
||||||
- server/network errors
|
|
||||||
- missing library/item identifiers
|
|
||||||
- no playable source
|
|
||||||
- mpv not connected for playback
|
|
||||||
|
|
||||||
## Security Notes and Limitations
|
|
||||||
|
|
||||||
- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup.
|
|
||||||
- Launcher wrappers support `--password-store=<backend>` and forward it through to the app process.
|
|
||||||
- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`.
|
|
||||||
- Treat both token storage and config files as secrets and avoid committing them.
|
|
||||||
- Password is used only for login and is not stored.
|
|
||||||
- Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags.
|
|
||||||
- `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`.
|
|
||||||
- For direct app CLI usage (`SubMiner.AppImage ...`), `--jellyfin-server` can override server URL for login/play flows without editing config.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# JLPT Vocabulary Bundle (Offline)
|
|
||||||
|
|
||||||
## Bundle location
|
|
||||||
|
|
||||||
SubMiner expects the JLPT term-meta bank files to be available locally at:
|
|
||||||
|
|
||||||
- `vendor/yomitan-jlpt-vocab`
|
|
||||||
|
|
||||||
At runtime, SubMiner also searches these derived locations:
|
|
||||||
|
|
||||||
- `vendor/yomitan-jlpt-vocab`
|
|
||||||
- `vendor/yomitan-jlpt-vocab/vendor/yomitan-jlpt-vocab`
|
|
||||||
- `vendor/yomitan-jlpt-vocab/yomitan-jlpt-vocab`
|
|
||||||
|
|
||||||
and user-data/config fallback paths (see `getJlptDictionarySearchPaths` in `src/main.ts`).
|
|
||||||
|
|
||||||
## Required files
|
|
||||||
|
|
||||||
The expected files are:
|
|
||||||
|
|
||||||
- `term_meta_bank_1.json`
|
|
||||||
- `term_meta_bank_2.json`
|
|
||||||
- `term_meta_bank_3.json`
|
|
||||||
- `term_meta_bank_4.json`
|
|
||||||
- `term_meta_bank_5.json`
|
|
||||||
|
|
||||||
Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging.
|
|
||||||
|
|
||||||
SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting, using installed/default `frequency-dictionary` locations or an explicit `subtitleStyle.frequencyDictionary.sourcePath`.
|
|
||||||
|
|
||||||
## Source and update process
|
|
||||||
|
|
||||||
For reproducible updates:
|
|
||||||
|
|
||||||
1. Obtain the JLPT term-meta bank archive from the same upstream source that supplies the bundled Yomitan dictionary data.
|
|
||||||
2. Extract the five `term_meta_bank_*.json` files.
|
|
||||||
3. Place them into `vendor/yomitan-jlpt-vocab/`.
|
|
||||||
4. Commit the update with the source URL/version in the task notes.
|
|
||||||
|
|
||||||
This repository currently ships the folder path in `electron-builder` `extraResources` as:
|
|
||||||
`vendor/yomitan-jlpt-vocab -> yomitan-jlpt-vocab`.
|
|
||||||
|
|
||||||
## Fallback Behavior
|
|
||||||
|
|
||||||
If bank files are missing, malformed, or lack expected metadata, SubMiner skips them gracefully. When no usable entries are found, JLPT underlining is silently disabled and subtitle rendering remains unchanged.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# Launcher Script
|
|
||||||
|
|
||||||
The `subminer` wrapper script is an all-in-one launcher that handles video selection, mpv startup, and overlay management. It's a Bun script distributed alongside the AppImage.
|
|
||||||
|
|
||||||
## Video Picker
|
|
||||||
|
|
||||||
When you run `subminer` without specifying a file, it opens an interactive video picker. By default it uses **fzf** in the terminal; pass `-R` to use **rofi** instead.
|
|
||||||
|
|
||||||
### fzf (default)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer # pick from current directory
|
|
||||||
subminer -d ~/Videos # pick from a specific directory
|
|
||||||
subminer -r -d ~/Anime # recursive search
|
|
||||||
```
|
|
||||||
|
|
||||||
fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback.
|
|
||||||
|
|
||||||
| Optional tool | Purpose |
|
|
||||||
| ------------------- | --------------------------------- |
|
|
||||||
| `chafa` | Render thumbnails in the terminal |
|
|
||||||
| `ffmpegthumbnailer` | Generate thumbnails on the fly |
|
|
||||||
|
|
||||||
### rofi
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer -R # rofi picker, current directory
|
|
||||||
subminer -R -d ~/Videos # rofi picker, specific directory
|
|
||||||
subminer -R -r -d ~/Anime # rofi picker, recursive
|
|
||||||
```
|
|
||||||
|
|
||||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme that can be installed from the release assets:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
|
||||||
```
|
|
||||||
|
|
||||||
The theme is auto-detected from these paths (first match wins):
|
|
||||||
|
|
||||||
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
|
||||||
- `$XDG_DATA_HOME/SubMiner/themes/subminer.rasi` (default: `~/.local/share/SubMiner/themes/subminer.rasi`)
|
|
||||||
- `/usr/local/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- `/usr/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
|
||||||
|
|
||||||
Override with the `SUBMINER_ROFI_THEME` environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
|
|
||||||
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
|
||||||
subminer -S video.mkv # same as above via --start-overlay
|
|
||||||
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
|
||||||
subminer ytsearch:"jp news" # YouTube search
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subcommands
|
|
||||||
|
|
||||||
| Subcommand | Purpose |
|
|
||||||
| -------------------------- | ---------------------------------------------------------- |
|
|
||||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
|
|
||||||
| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) |
|
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
|
||||||
| `subminer config path` | Print active config file path |
|
|
||||||
| `subminer config show` | Print active config contents |
|
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
|
||||||
| `subminer mpv socket` | Print active socket path |
|
|
||||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
|
||||||
| `subminer texthooker` | Launch texthooker-only mode |
|
|
||||||
| `subminer app` | Pass arguments directly to SubMiner binary |
|
|
||||||
|
|
||||||
Use `subminer <subcommand> -h` for command-specific help.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Flag | Description |
|
|
||||||
| --------------------- | --------------------------------------------------- |
|
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
|
||||||
| `-r, --recursive` | Search directories recursively |
|
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
|
||||||
| `--start` | Explicitly start overlay after mpv launches |
|
|
||||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
|
||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
|
||||||
| `-p, --profile` | mpv profile name (default: `subminer`) |
|
|
||||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
|
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
|
||||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
|
||||||
|
|
||||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
- Default log level is `info`
|
|
||||||
- `--background` mode defaults to `warn` unless `--log-level` is explicitly set
|
|
||||||
- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Mining Workflow
|
|
||||||
|
|
||||||
This guide walks through the sentence mining loop — from watching a video to creating Anki cards with audio, screenshots, and context.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You click a word to look it up with Yomitan, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki
|
|
||||||
↓
|
|
||||||
SubMiner auto-fills:
|
|
||||||
sentence, audio, image, translation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subtitle Delivery Path (Startup + Runtime)
|
|
||||||
|
|
||||||
SubMiner prioritizes subtitle responsiveness over heavy initialization:
|
|
||||||
|
|
||||||
1. The first subtitle render is **plain text first** (no tokenization wait).
|
|
||||||
2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes.
|
|
||||||
3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag.
|
|
||||||
4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization (configurable via `startupWarmups`, including low-power mode).
|
|
||||||
|
|
||||||
This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes.
|
|
||||||
|
|
||||||
## Overlay Model
|
|
||||||
|
|
||||||
SubMiner uses one overlay window with modal surfaces.
|
|
||||||
|
|
||||||
### Primary Subtitle Layer
|
|
||||||
|
|
||||||
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
|
||||||
|
|
||||||
- Word-level click targets for Yomitan lookup
|
|
||||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
|
||||||
- Optional auto-pause while Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
|
||||||
- Right-click to pause/resume
|
|
||||||
- Right-click + drag to reposition subtitles
|
|
||||||
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options
|
|
||||||
- **N+1 highlighting** — known words from your Anki deck are visually highlighted
|
|
||||||
|
|
||||||
Toggle visibility with `Alt+Shift+O` (global) or `y-t` (mpv plugin).
|
|
||||||
|
|
||||||
### Secondary Subtitle Bar
|
|
||||||
|
|
||||||
The secondary subtitle bar is a compact top-strip region in the same overlay window for translation/context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden.
|
|
||||||
|
|
||||||
It is controlled by `secondarySub` configuration and shares lifecycle with the main overlay window.
|
|
||||||
|
|
||||||
### Modal Surfaces
|
|
||||||
|
|
||||||
Jimaku search, field-grouping, runtime options, and manual subsync open as modal surfaces on top of the same overlay window.
|
|
||||||
|
|
||||||
## Looking Up Words
|
|
||||||
|
|
||||||
### On the Visible Overlay
|
|
||||||
|
|
||||||
1. Hover over the subtitle area — the overlay activates pointer events.
|
|
||||||
2. Click a word. SubMiner selects it using Unicode-aware word boundary detection (`Intl.Segmenter`).
|
|
||||||
3. Yomitan detects the text selection and opens its popup with dictionary results.
|
|
||||||
4. From the Yomitan popup, you can add the word directly to Anki.
|
|
||||||
|
|
||||||
### On Overlay Subtitles
|
|
||||||
|
|
||||||
1. Subtitles are rendered directly in the overlay.
|
|
||||||
2. Click on any word in the subtitle.
|
|
||||||
3. On macOS, word selection happens automatically on hover.
|
|
||||||
4. Yomitan popup appears for lookup and card creation.
|
|
||||||
|
|
||||||
## Creating Anki Cards
|
|
||||||
|
|
||||||
There are three ways to create cards, depending on your workflow.
|
|
||||||
|
|
||||||
### 1. Auto-Update from Yomitan
|
|
||||||
|
|
||||||
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
|
|
||||||
|
|
||||||
1. Click a word → Yomitan popup appears.
|
|
||||||
2. Click the Anki icon in Yomitan to add the word.
|
|
||||||
3. SubMiner receives or detects the new card:
|
|
||||||
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
|
||||||
- **Polling mode** (default): detects via AnkiConnect polling (`ankiConnect.pollingRate`, default 3 seconds).
|
|
||||||
4. SubMiner updates the card with:
|
|
||||||
- **Sentence**: The current subtitle line.
|
|
||||||
- **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding).
|
|
||||||
- **Image**: A screenshot or animated clip from the current playback position.
|
|
||||||
- **Translation**: From the secondary subtitle track, or generated via AI if configured.
|
|
||||||
- **MiscInfo**: Metadata like filename and timestamp.
|
|
||||||
|
|
||||||
Configure which fields to fill in `ankiConnect.fields`. See [Anki Integration](/anki-integration) for details.
|
|
||||||
|
|
||||||
### 2. Manual Update from Clipboard
|
|
||||||
|
|
||||||
If you prefer a hands-on approach (animecards-style), you can copy the current subtitle to the clipboard and then paste it onto the last-added Anki card:
|
|
||||||
|
|
||||||
1. Add a word via Yomitan as usual.
|
|
||||||
2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard.
|
|
||||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
|
||||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
|
||||||
|
|
||||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
|
||||||
| -------------------------- | ------------------------------- | --------------------------------------- |
|
|
||||||
| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` |
|
|
||||||
| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` |
|
|
||||||
| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` |
|
|
||||||
|
|
||||||
### 3. Mine Sentence (Hotkey)
|
|
||||||
|
|
||||||
Create a standalone sentence card without going through Yomitan:
|
|
||||||
|
|
||||||
- **Mine current sentence**: `Ctrl/Cmd+S` (configurable via `shortcuts.mineSentence`)
|
|
||||||
- **Mine multiple lines**: `Ctrl/Cmd+Shift+S` followed by a digit 1–9 to select how many recent subtitle lines to combine.
|
|
||||||
|
|
||||||
The sentence card uses the note type configured in `isLapis.sentenceCardModel` and always maps sentence/audio to `Sentence` and `SentenceAudio`.
|
|
||||||
|
|
||||||
### 4. Mark as Audio Card
|
|
||||||
|
|
||||||
After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing.
|
|
||||||
|
|
||||||
## Secondary Subtitles
|
|
||||||
|
|
||||||
SubMiner can display a secondary subtitle track (typically English) alongside the primary Japanese subtitles. This is useful for:
|
|
||||||
|
|
||||||
- Quick comprehension checks without leaving the mining flow.
|
|
||||||
- Auto-populating the translation field on mined cards.
|
|
||||||
|
|
||||||
### Display Modes
|
|
||||||
|
|
||||||
Cycle through modes with the configured shortcut:
|
|
||||||
|
|
||||||
- **Hidden**: Secondary subtitle not shown.
|
|
||||||
- **Visible**: Always displayed below the primary subtitle.
|
|
||||||
- **Hover**: Only shown when you hover over the primary subtitle.
|
|
||||||
|
|
||||||
When a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
|
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
|
||||||
|
|
||||||
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. You add a word via Yomitan.
|
|
||||||
2. SubMiner detects the new card and checks if a card with the same expression already exists.
|
|
||||||
3. If a duplicate is found:
|
|
||||||
- **Auto mode** (`fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
|
|
||||||
- **Manual mode** (`fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
|
|
||||||
|
|
||||||
### What Gets Merged
|
|
||||||
|
|
||||||
- **Sentence fields**: Both sentences kept, marked with `[Original]` and `[Duplicate]`.
|
|
||||||
- **Audio fields**: Both audio clips preserved as separate `[sound:...]` entries.
|
|
||||||
- **Image fields**: Both images preserved.
|
|
||||||
|
|
||||||
Configure in `ankiConnect.isKiku`. See [Anki Integration](/anki-integration#field-grouping-kiku) for the full reference.
|
|
||||||
|
|
||||||
## Jimaku Subtitle Search
|
|
||||||
|
|
||||||
SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay.
|
|
||||||
|
|
||||||
1. Open the Jimaku modal via the configured shortcut (`Ctrl+Shift+J` by default).
|
|
||||||
2. SubMiner auto-fills the search from the current video filename (title, season, episode).
|
|
||||||
3. Browse matching entries and select a subtitle file to download.
|
|
||||||
4. The subtitle is loaded into mpv as a new track.
|
|
||||||
|
|
||||||
Requires an internet connection. An API key (`jimaku.apiKey`) is optional but recommended for higher rate limits.
|
|
||||||
|
|
||||||
## Texthooker
|
|
||||||
|
|
||||||
SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time.
|
|
||||||
|
|
||||||
The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan.
|
|
||||||
|
|
||||||
## Subtitle Sync (Subsync)
|
|
||||||
|
|
||||||
If your subtitle file is out of sync with the audio, SubMiner can resynchronize it using [alass](https://github.com/kaegi/alass) or [ffsubsync](https://github.com/smacke/ffsubsync).
|
|
||||||
|
|
||||||
1. Open the subsync modal from the overlay.
|
|
||||||
2. Select the sync engine (alass or ffsubsync).
|
|
||||||
3. For alass, select a reference subtitle track from the video.
|
|
||||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
|
||||||
|
|
||||||
Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
|
||||||
|
|
||||||
## N+1 Word Highlighting
|
|
||||||
|
|
||||||
When enabled, SubMiner highlights words you already know in your Anki deck, making it easier to spot new (N+1) vocabulary during immersion.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. SubMiner periodically syncs with Anki to build a local cache of known words (expressions/headwords from your configured decks)
|
|
||||||
2. As subtitles appear, known words are visually highlighted in the visible overlay
|
|
||||||
3. Unknown words remain unhighlighted — these are your potential mining targets
|
|
||||||
|
|
||||||
### Enabling N+1 Mode
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ankiConnect": {
|
|
||||||
"nPlusOne": {
|
|
||||||
"highlightEnabled": true,
|
|
||||||
"refreshMinutes": 1440,
|
|
||||||
"matchMode": "headword",
|
|
||||||
"minSentenceWords": 3,
|
|
||||||
"decks": ["Learning::Japanese"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
| ------------------ | ----------------------------------------------------------------------------------- |
|
|
||||||
| `highlightEnabled` | Turn on/off the highlighting feature |
|
|
||||||
| `refreshMinutes` | How often to refresh the known-word cache (default: 1440 = daily) |
|
|
||||||
| `matchMode` | `"headword"` (dictionary form) or `"surface"` (exact text match) |
|
|
||||||
| `minSentenceWords` | Minimum sentence length in tokens required to allow N+1 highlighting (default: `3`) |
|
|
||||||
| `decks` | Which Anki decks to consider "known" (empty = uses `ankiConnect.deck`) |
|
|
||||||
|
|
||||||
### Use Cases
|
|
||||||
|
|
||||||
- **Immersion tracking**: Quickly identify which sentences contain only known words vs. those with new vocabulary
|
|
||||||
- **Mining focus**: Target sentences with exactly one unknown word (true N+1)
|
|
||||||
- **Progress visualization**: See your growing vocabulary visually represented in real content
|
|
||||||
|
|
||||||
### Immersion Tracking Storage
|
|
||||||
|
|
||||||
Immersion data is persisted to SQLite when enabled in `immersionTracking`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": true,
|
|
||||||
"dbPath": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `dbPath` can be empty (default) to use SubMiner’s app-data `immersion.sqlite`.
|
|
||||||
- Set an explicit path to move the database (for backups, cloud syncing, or easier inspection).
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
# MPV Plugin
|
|
||||||
|
|
||||||
The SubMiner mpv plugin (`subminer/main.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From release bundle:
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
|
||||||
mkdir -p ~/.config/SubMiner
|
|
||||||
cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc
|
|
||||||
mkdir -p ~/.config/mpv/scripts/subminer
|
|
||||||
mkdir -p ~/.config/mpv/script-opts
|
|
||||||
cp -R /tmp/plugin/subminer/. ~/.config/mpv/scripts/subminer/
|
|
||||||
cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/
|
|
||||||
|
|
||||||
# Or from source checkout: make install-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
mpv must have IPC enabled for SubMiner to connect:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# ~/.config/mpv/mpv.conf
|
|
||||||
input-ipc-server=/tmp/subminer-socket
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keybindings
|
|
||||||
|
|
||||||
All keybindings use a `y` chord prefix — press `y`, then the second key:
|
|
||||||
|
|
||||||
| Chord | Action |
|
|
||||||
| ----- | ---------------------- |
|
|
||||||
| `y-y` | Open menu |
|
|
||||||
| `y-s` | Start overlay |
|
|
||||||
| `y-S` | Stop overlay |
|
|
||||||
| `y-t` | Toggle visible overlay |
|
|
||||||
| `y-o` | Open settings window |
|
|
||||||
| `y-r` | Restart overlay |
|
|
||||||
| `y-c` | Check status |
|
|
||||||
| `y-k` | Skip intro (AniSkip) |
|
|
||||||
|
|
||||||
## Menu
|
|
||||||
|
|
||||||
Press `y-y` to open an interactive menu in mpv's OSD:
|
|
||||||
|
|
||||||
```text
|
|
||||||
SubMiner:
|
|
||||||
1. Start overlay
|
|
||||||
2. Stop overlay
|
|
||||||
3. Toggle overlay
|
|
||||||
4. Open options
|
|
||||||
5. Restart overlay
|
|
||||||
6. Check status
|
|
||||||
```
|
|
||||||
|
|
||||||
Select an item by pressing its number.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Create or edit `~/.config/mpv/script-opts/subminer.conf`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
# Path to SubMiner binary. Leave empty for auto-detection.
|
|
||||||
binary_path=
|
|
||||||
|
|
||||||
# MPV IPC socket path. Must match input-ipc-server in mpv.conf.
|
|
||||||
socket_path=/tmp/subminer-socket
|
|
||||||
|
|
||||||
# Enable the texthooker WebSocket server.
|
|
||||||
texthooker_enabled=yes
|
|
||||||
|
|
||||||
# Port for the texthooker server.
|
|
||||||
texthooker_port=5174
|
|
||||||
|
|
||||||
# Window manager backend: auto, hyprland, sway, x11, macos.
|
|
||||||
backend=auto
|
|
||||||
|
|
||||||
# Start the overlay automatically when a file is loaded.
|
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
|
||||||
auto_start=yes
|
|
||||||
|
|
||||||
# Show the visible overlay on auto-start.
|
|
||||||
# Runs only when mpv input-ipc-server matches socket_path.
|
|
||||||
auto_start_visible_overlay=yes
|
|
||||||
|
|
||||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
|
||||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
|
||||||
auto_start_pause_until_ready=yes
|
|
||||||
|
|
||||||
# Show OSD messages for overlay status changes.
|
|
||||||
osd_messages=yes
|
|
||||||
|
|
||||||
# Logging level: debug, info, warn, error.
|
|
||||||
log_level=info
|
|
||||||
|
|
||||||
# Enable AniSkip intro detection/markers.
|
|
||||||
aniskip_enabled=yes
|
|
||||||
|
|
||||||
# Optional title override (launcher fills from guessit when available).
|
|
||||||
aniskip_title=
|
|
||||||
|
|
||||||
# Optional season override (launcher fills from guessit when available).
|
|
||||||
aniskip_season=
|
|
||||||
|
|
||||||
# Optional MAL ID override. Leave blank to resolve from media title.
|
|
||||||
aniskip_mal_id=
|
|
||||||
|
|
||||||
# Optional episode override. Leave blank to detect from filename/title.
|
|
||||||
aniskip_episode=
|
|
||||||
|
|
||||||
# Show OSD skip button while inside intro range.
|
|
||||||
aniskip_show_button=yes
|
|
||||||
|
|
||||||
# OSD label + keybinding for intro skip action.
|
|
||||||
aniskip_button_text=You can skip by pressing %s
|
|
||||||
aniskip_button_key=y-k
|
|
||||||
aniskip_button_duration=3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option Reference
|
|
||||||
|
|
||||||
| Option | Default | Values | Description |
|
|
||||||
| ------------------------------ | ----------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
|
||||||
| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary |
|
|
||||||
| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path |
|
|
||||||
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
|
|
||||||
| `texthooker_port` | `5174` | 1–65535 | Texthooker server port |
|
|
||||||
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
|
|
||||||
| `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
|
|
||||||
| `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
|
|
||||||
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
|
|
||||||
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
|
|
||||||
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
|
|
||||||
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
|
|
||||||
| `aniskip_title` | `""` | string | Override title used for lookup |
|
|
||||||
| `aniskip_season` | `""` | numeric season | Optional season hint |
|
|
||||||
| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id |
|
|
||||||
| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed |
|
|
||||||
| `aniskip_payload` | `""` | JSON / base64-encoded JSON | Optional pre-fetched AniSkip payload for this media. When set, plugin skips network lookup |
|
|
||||||
| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt |
|
|
||||||
| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) |
|
|
||||||
| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) |
|
|
||||||
| `aniskip_button_duration` | `3` | float seconds | OSD hint duration |
|
|
||||||
|
|
||||||
## Binary Auto-Detection
|
|
||||||
|
|
||||||
When `binary_path` is empty, the plugin searches platform-specific locations:
|
|
||||||
|
|
||||||
**Linux:**
|
|
||||||
|
|
||||||
1. `~/.local/bin/SubMiner.AppImage`
|
|
||||||
2. `/opt/SubMiner/SubMiner.AppImage`
|
|
||||||
3. `/usr/local/bin/SubMiner`
|
|
||||||
4. `/usr/bin/SubMiner`
|
|
||||||
|
|
||||||
**macOS:**
|
|
||||||
|
|
||||||
1. `/Applications/SubMiner.app/Contents/MacOS/SubMiner`
|
|
||||||
2. `~/Applications/SubMiner.app/Contents/MacOS/SubMiner`
|
|
||||||
|
|
||||||
**Windows:**
|
|
||||||
|
|
||||||
1. `C:\Program Files\SubMiner\SubMiner.exe`
|
|
||||||
2. `C:\Program Files (x86)\SubMiner\SubMiner.exe`
|
|
||||||
3. `C:\SubMiner\SubMiner.exe`
|
|
||||||
|
|
||||||
## Backend Detection
|
|
||||||
|
|
||||||
When `backend=auto`, the plugin detects the window manager:
|
|
||||||
|
|
||||||
1. **macOS** — detected via platform or `OSTYPE`.
|
|
||||||
2. **Hyprland** — detected via `HYPRLAND_INSTANCE_SIGNATURE`.
|
|
||||||
3. **Sway** — detected via `SWAYSOCK`.
|
|
||||||
4. **X11** — detected via `XDG_SESSION_TYPE=x11` or `DISPLAY`.
|
|
||||||
5. **Fallback** — defaults to X11 with a warning.
|
|
||||||
|
|
||||||
## Script Messages
|
|
||||||
|
|
||||||
The plugin can be controlled from other mpv scripts or the mpv command line using script messages:
|
|
||||||
|
|
||||||
```
|
|
||||||
script-message subminer-start
|
|
||||||
script-message subminer-stop
|
|
||||||
script-message subminer-toggle
|
|
||||||
script-message subminer-menu
|
|
||||||
script-message subminer-options
|
|
||||||
script-message subminer-restart
|
|
||||||
script-message subminer-status
|
|
||||||
script-message subminer-autoplay-ready
|
|
||||||
script-message subminer-aniskip-refresh
|
|
||||||
script-message subminer-skip-intro
|
|
||||||
```
|
|
||||||
|
|
||||||
The `subminer-start` message accepts overrides:
|
|
||||||
|
|
||||||
```
|
|
||||||
script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug
|
|
||||||
```
|
|
||||||
|
|
||||||
`log-level` here controls only logging verbosity passed to SubMiner.
|
|
||||||
`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging.
|
|
||||||
|
|
||||||
## AniSkip Intro Skip
|
|
||||||
|
|
||||||
- AniSkip lookups are gated. The plugin only runs lookup when:
|
|
||||||
- SubMiner launcher metadata is present, or
|
|
||||||
- SubMiner app process is already running, or
|
|
||||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
|
||||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
|
||||||
- MAL/title resolution is cached for the current mpv session.
|
|
||||||
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
|
||||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
|
||||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
|
||||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
|
||||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default).
|
|
||||||
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
|
||||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
|
||||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
|
||||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
|
||||||
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.
|
|
||||||
|
|
||||||
## Using with the `subminer` Wrapper
|
|
||||||
|
|
||||||
The `subminer` wrapper script handles mpv launch, socket setup, and overlay lifecycle automatically. You do not need the plugin if you always use the wrapper.
|
|
||||||
|
|
||||||
The plugin is useful when you:
|
|
||||||
|
|
||||||
- Launch mpv from other tools (file managers, media centers).
|
|
||||||
- Want on-demand overlay control without the wrapper.
|
|
||||||
- Use mpv's built-in file browser or playlist features.
|
|
||||||
|
|
||||||
You can install both — the plugin provides chord keybindings for convenience, while the wrapper handles the full lifecycle.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Secondary Subtitles Main Overlay Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Ensure secondary subtitles render in the unified main overlay window and remove stale secondary-window/layer paths.
|
|
||||||
|
|
||||||
**Architecture:** Keep secondary subtitle DOM in the shared renderer tree, rely on mode classes (`secondary-sub-hidden|visible|hover`) for visibility, and remove obsolete legacy overlay-layer assumptions. Preserve modal behavior and existing subtitle rendering flow.
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, Electron renderer CSS/DOM, Bun test runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add Regression Tests For Main Overlay Secondary Rendering
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/renderer/subtitle-render.test.ts`
|
|
||||||
- Modify: `src/renderer/error-recovery.test.ts`
|
|
||||||
|
|
||||||
**Step 1: Write failing tests**
|
|
||||||
|
|
||||||
- Assert stylesheet no longer hides secondary subtitles in `layer-visible`.
|
|
||||||
- Assert renderer platform resolution ignores legacy `secondary` overlay layer.
|
|
||||||
|
|
||||||
**Step 2: Run tests to verify failures**
|
|
||||||
|
|
||||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
|
||||||
Expected: FAIL on secondary subtitle hide rule + legacy secondary layer handling.
|
|
||||||
|
|
||||||
### Task 2: Remove Secondary-Window CSS/Routing Assumptions
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `src/renderer/style.css`
|
|
||||||
- Modify: `src/renderer/utils/platform.ts`
|
|
||||||
- Modify: `src/renderer/error-recovery.ts`
|
|
||||||
- Modify: `src/types.ts`
|
|
||||||
|
|
||||||
**Step 1: Implement minimal changes**
|
|
||||||
|
|
||||||
- Remove legacy forced hide on `#secondarySubContainer`.
|
|
||||||
- Remove obsolete layer-specific secondary-subtitle CSS blocks.
|
|
||||||
- Drop legacy `secondary` overlay-layer parsing path from renderer platform resolver.
|
|
||||||
- Narrow related overlay layer type unions.
|
|
||||||
|
|
||||||
**Step 2: Run targeted tests**
|
|
||||||
|
|
||||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 3: Validate Wider Related Surface
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- No additional code changes required.
|
|
||||||
|
|
||||||
**Step 1: Run broader related tests**
|
|
||||||
|
|
||||||
Run: `bun test src/renderer/subtitle-render.test.ts src/renderer/error-recovery.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts src/main/runtime/overlay-window-factory.test.ts src/core/services/overlay-manager.test.ts`
|
|
||||||
Expected: Renderer tests pass; report any unrelated pre-existing failures.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { expect, test } from 'bun:test';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
|
|
||||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
|
||||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
|
||||||
|
|
||||||
test('docs theme has no plausible analytics wiring', () => {
|
|
||||||
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
|
|
||||||
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
|
||||||
expect(docsThemeContents).not.toContain('worker.subminer.moe');
|
|
||||||
expect(docsThemeContents).not.toContain('domain:');
|
|
||||||
expect(docsThemeContents).not.toContain('outboundLinks: true');
|
|
||||||
expect(docsThemeContents).not.toContain('fileDownloads: true');
|
|
||||||
expect(docsThemeContents).not.toContain('formSubmissions: true');
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -1,40 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="ac-card" x1="6" y1="8" x2="38" y2="44" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#34d399"/>
|
|
||||||
<stop offset="1" stop-color="#059669"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="ac-glow" x1="8" y1="10" x2="36" y2="42" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#6ee7b7" stop-opacity="0.5"/>
|
|
||||||
<stop offset="1" stop-color="#059669" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="ac-soft" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.2"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<!-- Glow aura behind card -->
|
|
||||||
<rect x="6" y="8" width="28" height="36" rx="5" fill="url(#ac-glow)" filter="url(#ac-soft)"/>
|
|
||||||
<!-- Shadow card (back) -->
|
|
||||||
<rect x="14" y="5" width="26" height="34" rx="4" fill="#059669" opacity="0.15"/>
|
|
||||||
<!-- Main card -->
|
|
||||||
<rect x="6" y="9" width="26" height="34" rx="4" fill="url(#ac-card)"/>
|
|
||||||
<!-- Sentence line -->
|
|
||||||
<rect x="10" y="16" width="16" height="2.5" rx="1.25" fill="white" opacity="0.9"/>
|
|
||||||
<!-- Audio waveform mini -->
|
|
||||||
<rect x="10" y="22" width="1.8" height="5" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<rect x="13" y="20.5" width="1.8" height="8" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<rect x="16" y="21.5" width="1.8" height="6" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<rect x="19" y="23" width="1.8" height="3" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<rect x="22" y="21" width="1.8" height="7" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<rect x="25" y="22.5" width="1.8" height="4" rx="0.9" fill="white" opacity="0.55"/>
|
|
||||||
<!-- Image thumbnail placeholder -->
|
|
||||||
<rect x="10" y="30" width="10" height="8" rx="2" fill="white" opacity="0.25"/>
|
|
||||||
<path d="M12.5 35.5l2-2.5 2 1.8 1.5-1 2.5 3h-8z" fill="white" opacity="0.5"/>
|
|
||||||
<!-- Translation line -->
|
|
||||||
<rect x="22" y="32" width="7" height="2" rx="1" fill="white" opacity="0.35"/>
|
|
||||||
<rect x="22" y="35.5" width="5" height="2" rx="1" fill="white" opacity="0.25"/>
|
|
||||||
<!-- Enrichment sparkle burst -->
|
|
||||||
<path d="M40 10l1.6 3.8 3.8 1.6-3.8 1.6L40 20.8l-1.6-3.8L34.6 15.4l3.8-1.6z" fill="#6ee7b7"/>
|
|
||||||
<path d="M37 29l0.9 2.1 2.1 0.9-2.1 0.9L37 35l-0.9-2.1-2.1-0.9 2.1-0.9z" fill="#6ee7b7" opacity="0.5"/>
|
|
||||||
<circle cx="43" cy="25" r="1.2" fill="#34d399" opacity="0.4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 458 KiB |
@@ -1,15 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="dl" x1="4" y1="24" x2="44" y2="24" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#818cf8"/>
|
|
||||||
<stop offset="1" stop-color="#6366f1"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="4" y="6" width="40" height="14" rx="4" fill="#818cf8" opacity="0.12"/>
|
|
||||||
<rect x="4" y="6" width="40" height="14" rx="4" stroke="#818cf8" stroke-width="1.5" stroke-dasharray="4 3" fill="none" opacity="0.55"/>
|
|
||||||
<rect x="10" y="11" width="20" height="3" rx="1.5" fill="#818cf8" opacity="0.35"/>
|
|
||||||
<line x1="24" y1="22" x2="24" y2="26" stroke="#a5b4fc" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
|
|
||||||
<path d="M21.5 24.5L24 27l2.5-2.5" stroke="#a5b4fc" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.5"/>
|
|
||||||
<rect x="4" y="28" width="40" height="14" rx="4" fill="url(#dl)"/>
|
|
||||||
<rect x="10" y="33" width="20" height="3" rx="1.5" fill="white" opacity="0.85"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,39 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="hl-freq" x1="0" y1="0" x2="14" y2="8" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#fbbf24"/>
|
|
||||||
<stop offset="1" stop-color="#f59e0b"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="hl-n1" x1="0" y1="0" x2="10" y2="10" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#60a5fa"/>
|
|
||||||
<stop offset="1" stop-color="#3b82f6"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="hl-jlpt" x1="0" y1="0" x2="12" y2="8" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#a78bfa"/>
|
|
||||||
<stop offset="1" stop-color="#7c3aed"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Viewport / video frame background -->
|
|
||||||
<rect x="1" y="5" width="46" height="38" rx="4" fill="#1e293b" opacity="0.55"/>
|
|
||||||
<rect x="1" y="5" width="46" height="38" rx="4" stroke="#334155" stroke-width="0.8" fill="none" opacity="0.5"/>
|
|
||||||
<!-- Subtitle line 1 — tokens with frequency highlight -->
|
|
||||||
<rect x="6" y="18" width="9" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
|
||||||
<!-- Frequency-highlighted token -->
|
|
||||||
<rect x="17" y="17" width="14" height="7" rx="2" fill="url(#hl-freq)" opacity="0.2"/>
|
|
||||||
<rect x="17.5" y="17.5" width="13" height="6" rx="1.8" fill="url(#hl-freq)"/>
|
|
||||||
<rect x="20" y="19.5" width="8" height="2" rx="1" fill="white" opacity="0.85"/>
|
|
||||||
<rect x="33" y="18" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
|
||||||
<!-- Subtitle line 2 — tokens with N+1 dot and JLPT badge -->
|
|
||||||
<rect x="8" y="28" width="8" height="5" rx="1.5" fill="#cbd5e1" opacity="0.2"/>
|
|
||||||
<!-- N+1 targeted token with dot -->
|
|
||||||
<rect x="18" y="28" width="10" height="5" rx="1.5" fill="#60a5fa" opacity="0.15"/>
|
|
||||||
<rect x="18.5" y="28.5" width="9" height="4" rx="1.2" fill="#cbd5e1" opacity="0.3"/>
|
|
||||||
<circle cx="16.5" cy="30.5" r="2.2" fill="url(#hl-n1)"/>
|
|
||||||
<text x="16.5" y="31.9" text-anchor="middle" font-size="2.6" font-weight="800" fill="white" font-family="sans-serif">+1</text>
|
|
||||||
<!-- JLPT badge token -->
|
|
||||||
<rect x="30" y="28" width="7" height="5" rx="1.5" fill="#cbd5e1" opacity="0.3"/>
|
|
||||||
<rect x="37.5" y="27" width="9" height="7" rx="2" fill="url(#hl-jlpt)"/>
|
|
||||||
<text x="42" y="31.8" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">N2</text>
|
|
||||||
<!-- Subtle sparkle -->
|
|
||||||
<path d="M43 10l0.7 1.6 1.6 0.7-1.6 0.7L43 14.6l-0.7-1.6-1.6-0.7 1.6-0.7z" fill="#fbbf24" opacity="0.5"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,31 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="kb-main" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#c084fc"/>
|
|
||||||
<stop offset="1" stop-color="#7c3aed"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="kb-glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<!-- Keyboard body -->
|
|
||||||
<rect x="2" y="14" width="44" height="28" rx="4.5" fill="url(#kb-main)" opacity="0.1"/>
|
|
||||||
<rect x="2" y="14" width="44" height="28" rx="4.5" stroke="url(#kb-main)" stroke-width="1.4" fill="none"/>
|
|
||||||
<!-- Row 1 -->
|
|
||||||
<rect x="6" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<rect x="15" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<rect x="24" y="18" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<rect x="33" y="18" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<!-- Row 2 — active key with glow -->
|
|
||||||
<rect x="6" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<!-- Active/pressed key glow -->
|
|
||||||
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="#c084fc" opacity="0.25" filter="url(#kb-glow)"/>
|
|
||||||
<rect x="15" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)"/>
|
|
||||||
<text x="18.5" y="30" text-anchor="middle" font-size="3.5" font-weight="700" fill="white" font-family="sans-serif">M</text>
|
|
||||||
<rect x="24" y="25.5" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<rect x="33" y="25.5" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<!-- Row 3 — spacebar -->
|
|
||||||
<rect x="6" y="33" width="7" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
<rect x="15" y="33" width="16" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.25"/>
|
|
||||||
<rect x="33" y="33" width="11" height="5.5" rx="1.8" fill="url(#kb-main)" opacity="0.3"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 23 MiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 21 MiB |
@@ -1,86 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
width="64"
|
|
||||||
height="64"
|
|
||||||
viewBox="0 0 63.999999 63.999999"
|
|
||||||
id="svg2"
|
|
||||||
version="1.1"
|
|
||||||
inkscape:version="0.91 r13725"
|
|
||||||
sodipodi:docname="mpv.svg">
|
|
||||||
<defs
|
|
||||||
id="defs4" />
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="base"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#666666"
|
|
||||||
borderopacity="1.0"
|
|
||||||
inkscape:pageopacity="0.0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:zoom="5.3710484"
|
|
||||||
inkscape:cx="10.112865"
|
|
||||||
inkscape:cy="18.643164"
|
|
||||||
inkscape:document-units="px"
|
|
||||||
inkscape:current-layer="layer1"
|
|
||||||
showgrid="false"
|
|
||||||
units="px"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1016"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="27"
|
|
||||||
inkscape:window-maximized="1" />
|
|
||||||
<metadata
|
|
||||||
id="metadata7">
|
|
||||||
<rdf:RDF>
|
|
||||||
<cc:Work
|
|
||||||
rdf:about="">
|
|
||||||
<dc:format>image/svg+xml</dc:format>
|
|
||||||
<dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
|
||||||
<dc:title></dc:title>
|
|
||||||
</cc:Work>
|
|
||||||
</rdf:RDF>
|
|
||||||
</metadata>
|
|
||||||
<g
|
|
||||||
inkscape:label="Layer 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(0,-988.3622)">
|
|
||||||
<circle
|
|
||||||
style="opacity:1;fill:#e5e5e5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.10161044;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686"
|
|
||||||
id="path4380"
|
|
||||||
cx="32"
|
|
||||||
cy="1020.3622"
|
|
||||||
r="27.949194" />
|
|
||||||
<circle
|
|
||||||
style="opacity:1;fill:#672168;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0988237;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686"
|
|
||||||
id="path4390"
|
|
||||||
cx="32.727058"
|
|
||||||
cy="1019.5079"
|
|
||||||
r="25.950588" />
|
|
||||||
<circle
|
|
||||||
style="opacity:1;fill:#420143;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686"
|
|
||||||
id="path4400"
|
|
||||||
cx="34.224396"
|
|
||||||
cy="1017.7957"
|
|
||||||
r="20" />
|
|
||||||
<path
|
|
||||||
style="fill:#dddbdd;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
d="m 44.481446,1020.4807 a 12.848894,12.848894 0 0 1 -12.84889,12.8489 12.848894,12.848894 0 0 1 -12.8489,-12.8489 12.848894,12.848894 0 0 1 12.8489,-12.8489 12.848894,12.848894 0 0 1 12.84889,12.8489 z"
|
|
||||||
id="path4412"
|
|
||||||
inkscape:connector-curvature="0" />
|
|
||||||
<path
|
|
||||||
style="fill:#691f69;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
|
||||||
d="m 28.374316,1014.709 0,11.4502 9.21608,-5.8647 z"
|
|
||||||
id="path4426"
|
|
||||||
inkscape:connector-curvature="0" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,35 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="sd-main" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#22d3ee"/>
|
|
||||||
<stop offset="1" stop-color="#0891b2"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="sd-sync" x1="30" y1="28" x2="46" y2="44" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#34d399"/>
|
|
||||||
<stop offset="1" stop-color="#059669"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Subtitle file -->
|
|
||||||
<rect x="4" y="3" width="26" height="34" rx="3.5" fill="url(#sd-main)" opacity="0.12"/>
|
|
||||||
<rect x="4" y="3" width="26" height="34" rx="3.5" stroke="url(#sd-main)" stroke-width="1.4" fill="none"/>
|
|
||||||
<!-- SRT-style timing line -->
|
|
||||||
<rect x="8.5" y="10" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
|
||||||
<rect x="20" y="10" width="3" height="2" rx="1" fill="#22d3ee" opacity="0.25"/>
|
|
||||||
<!-- Subtitle text lines -->
|
|
||||||
<rect x="8.5" y="15" width="17" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.6"/>
|
|
||||||
<rect x="8.5" y="20" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
|
||||||
<!-- Divider -->
|
|
||||||
<line x1="8.5" y1="25.5" x2="26" y2="25.5" stroke="#22d3ee" stroke-width="0.6" opacity="0.2"/>
|
|
||||||
<!-- Second block timing -->
|
|
||||||
<rect x="8.5" y="28" width="10" height="2" rx="1" fill="#22d3ee" opacity="0.35"/>
|
|
||||||
<!-- Second block text -->
|
|
||||||
<rect x="8.5" y="32.5" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.4"/>
|
|
||||||
<!-- Download arrow -->
|
|
||||||
<line x1="38" y1="6" x2="38" y2="20" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
<path d="M33 16.5l5 5.5 5-5.5" stroke="url(#sd-main)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<!-- Sync arrows (circular) -->
|
|
||||||
<path d="M35 35a6 6 0 0 1 8.5-1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M44.5 35.5l-1-2.8-2.8 1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
<path d="M43.5 41a6 6 0 0 1-8.5 1.5" stroke="url(#sd-sync)" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M34 40.5l1 2.8 2.8-1" stroke="url(#sd-sync)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,46 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="th-main" x1="2" y1="6" x2="22" y2="42" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#f97316"/>
|
|
||||||
<stop offset="1" stop-color="#c2410c"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="th-browser" x1="28" y1="6" x2="46" y2="42" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#fb923c"/>
|
|
||||||
<stop offset="1" stop-color="#ea580c"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Source panel (subtitle/text source) -->
|
|
||||||
<rect x="2" y="8" width="18" height="32" rx="3" fill="url(#th-main)" opacity="0.12"/>
|
|
||||||
<rect x="2" y="8" width="18" height="32" rx="3" stroke="url(#th-main)" stroke-width="1.3" fill="none"/>
|
|
||||||
<!-- Subtitle text lines streaming out -->
|
|
||||||
<rect x="5" y="14" width="12" height="2" rx="1" fill="#f97316" opacity="0.6"/>
|
|
||||||
<rect x="5" y="19" width="10" height="2" rx="1" fill="#f97316" opacity="0.5"/>
|
|
||||||
<rect x="5" y="24" width="11" height="2" rx="1" fill="#f97316" opacity="0.4"/>
|
|
||||||
<rect x="5" y="29" width="9" height="2" rx="1" fill="#f97316" opacity="0.35"/>
|
|
||||||
<rect x="5" y="34" width="12" height="2" rx="1" fill="#f97316" opacity="0.3"/>
|
|
||||||
<!-- WebSocket stream particles -->
|
|
||||||
<circle cx="23" cy="18" r="1.2" fill="#fb923c" opacity="0.7"/>
|
|
||||||
<circle cx="25" cy="24" r="1" fill="#fb923c" opacity="0.5"/>
|
|
||||||
<circle cx="23.5" cy="30" r="1.1" fill="#fb923c" opacity="0.4"/>
|
|
||||||
<!-- Connection line (wavy/flowing) -->
|
|
||||||
<path d="M20 15c2-1 4 2 6 1s3-3 5-2" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.3"/>
|
|
||||||
<path d="M20 24c2.5 0 3 2 5 1.5s3-2.5 5.5-1.5" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.25"/>
|
|
||||||
<path d="M20 33c2-1 3.5 1.5 5.5 0.5s3-2 5-1" stroke="#fb923c" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.2"/>
|
|
||||||
<!-- Browser window (destination) -->
|
|
||||||
<rect x="28" y="8" width="18" height="32" rx="3" fill="url(#th-browser)" opacity="0.12"/>
|
|
||||||
<rect x="28" y="8" width="18" height="32" rx="3" stroke="url(#th-browser)" stroke-width="1.3" fill="none"/>
|
|
||||||
<!-- Browser chrome dots -->
|
|
||||||
<circle cx="32" cy="12" r="1.2" fill="#f97316" opacity="0.45"/>
|
|
||||||
<circle cx="35.5" cy="12" r="1.2" fill="#f97316" opacity="0.35"/>
|
|
||||||
<circle cx="39" cy="12" r="1.2" fill="#f97316" opacity="0.25"/>
|
|
||||||
<!-- Browser address bar -->
|
|
||||||
<rect x="31" y="15.5" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.15"/>
|
|
||||||
<!-- Received text lines in browser -->
|
|
||||||
<rect x="31" y="21" width="11" height="2" rx="1" fill="#fb923c" opacity="0.55"/>
|
|
||||||
<rect x="31" y="25.5" width="9" height="2" rx="1" fill="#fb923c" opacity="0.45"/>
|
|
||||||
<rect x="31" y="30" width="10" height="2" rx="1" fill="#fb923c" opacity="0.35"/>
|
|
||||||
<rect x="31" y="34.5" width="8" height="2" rx="1" fill="#fb923c" opacity="0.25"/>
|
|
||||||
<!-- WS label -->
|
|
||||||
<rect x="21" y="5" width="8" height="5.5" rx="2.5" fill="#c2410c"/>
|
|
||||||
<text x="25" y="9.2" text-anchor="middle" font-size="3.2" font-weight="800" fill="white" font-family="sans-serif">WS</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,34 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="tk-bar" x1="0" y1="40" x2="0" y2="10" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#0891b2"/>
|
|
||||||
<stop offset="1" stop-color="#22d3ee"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="tk-glow" x1="4" y1="40" x2="44" y2="10" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#22d3ee" stop-opacity="0.25"/>
|
|
||||||
<stop offset="1" stop-color="#06b6d4" stop-opacity="0"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Subtle grid lines -->
|
|
||||||
<line x1="4" y1="14" x2="44" y2="14" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
|
||||||
<line x1="4" y1="22" x2="44" y2="22" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
|
||||||
<line x1="4" y1="30" x2="44" y2="30" stroke="#22d3ee" stroke-width="0.5" opacity="0.12"/>
|
|
||||||
<!-- Base line -->
|
|
||||||
<line x1="4" y1="40" x2="44" y2="40" stroke="#0891b2" stroke-width="1" opacity="0.3"/>
|
|
||||||
<!-- Activity bars (daily rollups) -->
|
|
||||||
<rect x="5" y="30" width="4" height="10" rx="1.5" fill="url(#tk-bar)" opacity="0.4"/>
|
|
||||||
<rect x="11" y="24" width="4" height="16" rx="1.5" fill="url(#tk-bar)" opacity="0.55"/>
|
|
||||||
<rect x="17" y="28" width="4" height="12" rx="1.5" fill="url(#tk-bar)" opacity="0.5"/>
|
|
||||||
<rect x="23" y="18" width="4" height="22" rx="1.5" fill="url(#tk-bar)" opacity="0.7"/>
|
|
||||||
<rect x="29" y="22" width="4" height="18" rx="1.5" fill="url(#tk-bar)" opacity="0.6"/>
|
|
||||||
<rect x="35" y="14" width="4" height="26" rx="1.5" fill="url(#tk-bar)"/>
|
|
||||||
<rect x="41" y="20" width="4" height="20" rx="1.5" fill="url(#tk-bar)" opacity="0.65"/>
|
|
||||||
<!-- Trend line -->
|
|
||||||
<polyline points="7,28 13,22 19,25.5 25,16 31,20 37,12 43,18" stroke="#67e8f9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.7"/>
|
|
||||||
<!-- Trend dot on peak -->
|
|
||||||
<circle cx="37" cy="12" r="2.2" fill="#22d3ee" opacity="0.6"/>
|
|
||||||
<circle cx="37" cy="12" r="1" fill="white" opacity="0.9"/>
|
|
||||||
<!-- Mini counter badge -->
|
|
||||||
<rect x="33" y="4" width="12" height="7" rx="3.5" fill="#0891b2"/>
|
|
||||||
<text x="39" y="9" text-anchor="middle" font-size="4" font-weight="700" fill="white" font-family="sans-serif">42d</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="vd" x1="4" y1="10" x2="44" y2="38" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="#fb7185"/>
|
|
||||||
<stop offset="1" stop-color="#e11d48"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect x="4" y="10" width="40" height="28" rx="4" fill="url(#vd)" opacity="0.15"/>
|
|
||||||
<rect x="4" y="10" width="40" height="28" rx="4" stroke="url(#vd)" stroke-width="1.5" fill="none"/>
|
|
||||||
<path d="M20 18l12 6-12 6z" fill="url(#vd)"/>
|
|
||||||
<rect x="10" y="32" width="22" height="2.5" rx="1.25" fill="white" opacity="0.4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 635 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><defs><linearGradient id="a" x1="11.876" x2="4.014" y1="4.073" y2="11.935" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#bc00ff" stop-opacity=".941" style="stop-color:#bc00ff;stop-opacity:1"/><stop offset="1" stop-color="#00b9fe"/></linearGradient></defs><rect width="16" height="16" fill="url(#a)" rx="1.625" ry="1.625"/><path d="M2 2v2h3v3H2v2h3v3H2v2h5V2Zm7 0v2h5V2Zm0 5v2h5V7Zm0 5v2h5v-2z" shape-rendering="crispEdges" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 527 B |
@@ -1,327 +0,0 @@
|
|||||||
/**
|
|
||||||
* SubMiner Example Configuration File
|
|
||||||
*
|
|
||||||
* This file is auto-generated from src/config/definitions.ts.
|
|
||||||
* Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
// ==========================================
|
|
||||||
// Overlay Auto-Start
|
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
|
||||||
// ==========================================
|
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Texthooker Server
|
|
||||||
// Control whether browser opens automatically for texthooker.
|
|
||||||
// ==========================================
|
|
||||||
"texthooker": {
|
|
||||||
"openBrowser": true, // Open browser setting. Values: true | false
|
|
||||||
}, // Control whether browser opens automatically for texthooker.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WebSocket Server
|
|
||||||
// Built-in WebSocket server broadcasts subtitle text to connected clients.
|
|
||||||
// Auto mode disables built-in server if mpv_websocket is detected.
|
|
||||||
// ==========================================
|
|
||||||
"websocket": {
|
|
||||||
"enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false
|
|
||||||
"port": 6677, // Built-in subtitle websocket server port.
|
|
||||||
}, // Built-in WebSocket server broadcasts subtitle text to connected clients.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Logging
|
|
||||||
// Controls logging verbosity.
|
|
||||||
// Set to debug for full runtime diagnostics.
|
|
||||||
// ==========================================
|
|
||||||
"logging": {
|
|
||||||
"level": "info", // Minimum log level for runtime logging. Values: debug | info | warn | error
|
|
||||||
}, // Controls logging verbosity.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Keyboard Shortcuts
|
|
||||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
|
||||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
|
||||||
// ==========================================
|
|
||||||
"shortcuts": {
|
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
|
||||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
|
||||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
|
||||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
|
||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Keybindings (MPV Commands)
|
|
||||||
// Extra keybindings that are merged with built-in defaults.
|
|
||||||
// Set command to null to disable a default keybinding.
|
|
||||||
// Hot-reload: keybinding changes apply live and update the session help modal on reopen.
|
|
||||||
// ==========================================
|
|
||||||
"keybindings": [], // Extra keybindings that are merged with built-in defaults.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Secondary Subtitles
|
|
||||||
// Dual subtitle track options.
|
|
||||||
// Used by subminer YouTube subtitle generation as secondary language preferences.
|
|
||||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
|
||||||
// ==========================================
|
|
||||||
"secondarySub": {
|
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
|
||||||
"defaultMode": "hover", // Default mode setting.
|
|
||||||
}, // Dual subtitle track options.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Auto Subtitle Sync
|
|
||||||
// Subsync engine and executable paths.
|
|
||||||
// ==========================================
|
|
||||||
"subsync": {
|
|
||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
|
||||||
"alass_path": "", // Alass path setting.
|
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
|
||||||
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
|
||||||
}, // Subsync engine and executable paths.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Subtitle Position
|
|
||||||
// Initial vertical subtitle position from the bottom.
|
|
||||||
// ==========================================
|
|
||||||
"subtitlePosition": {
|
|
||||||
"yPercent": 10, // Y percent setting.
|
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Subtitle Appearance
|
|
||||||
// Primary and secondary subtitle styling.
|
|
||||||
// Hot-reload: subtitle style changes apply live without restarting SubMiner.
|
|
||||||
// ==========================================
|
|
||||||
"subtitleStyle": {
|
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text; resume after leaving subtitle area. Values: true | false
|
|
||||||
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open; resume when popup closes. Values: true | false
|
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
|
||||||
"fontFamily": "M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
|
||||||
"fontSize": 35, // Font size setting.
|
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"fontWeight": "600", // Font weight setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
"backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
|
||||||
"jlptColors": {
|
|
||||||
"N1": "#ed8796", // N1 setting.
|
|
||||||
"N2": "#f5a97f", // N2 setting.
|
|
||||||
"N3": "#f9e2af", // N3 setting.
|
|
||||||
"N4": "#a6e3a1", // N4 setting.
|
|
||||||
"N5": "#8aadf4", // N5 setting.
|
|
||||||
}, // Jlpt colors setting.
|
|
||||||
"frequencyDictionary": {
|
|
||||||
"enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false
|
|
||||||
"sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, SubMiner searches installed/default frequency-dictionary locations.
|
|
||||||
"topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000).
|
|
||||||
"mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded
|
|
||||||
"matchMode": "headword", // Frequency lookup text selection mode. Values: headword | surface
|
|
||||||
"singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`.
|
|
||||||
"bandedColors": ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"], // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
|
||||||
}, // Frequency dictionary setting.
|
|
||||||
"secondary": {
|
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
|
||||||
"fontSize": 24, // Font size setting.
|
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 3px 10px rgba(0,0,0,0.69)", // Text shadow setting.
|
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"fontWeight": "normal", // Font weight setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
}, // Secondary setting.
|
|
||||||
}, // Primary and secondary subtitle styling.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// AnkiConnect Integration
|
|
||||||
// Automatic Anki updates and media generation options.
|
|
||||||
// Hot-reload: AI translation settings update live while SubMiner is running.
|
|
||||||
// Most other AnkiConnect settings still require restart.
|
|
||||||
// ==========================================
|
|
||||||
"ankiConnect": {
|
|
||||||
"enabled": false, // Enable AnkiConnect integration. Values: true | false
|
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
|
||||||
"proxy": {
|
|
||||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
|
||||||
"host": "127.0.0.1", // Bind host for local AnkiConnect proxy.
|
|
||||||
"port": 8766, // Bind port for local AnkiConnect proxy.
|
|
||||||
"upstreamUrl": "http://127.0.0.1:8765", // Upstream AnkiConnect URL proxied by local AnkiConnect proxy.
|
|
||||||
}, // Proxy setting.
|
|
||||||
"tags": ["SubMiner"], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
|
||||||
"fields": {
|
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
|
||||||
"image": "Picture", // Image setting.
|
|
||||||
"sentence": "Sentence", // Sentence setting.
|
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
|
||||||
"translation": "SelectionText", // Translation setting.
|
|
||||||
}, // Fields setting.
|
|
||||||
"ai": {
|
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
|
||||||
"alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false
|
|
||||||
"apiKey": "", // Api key setting.
|
|
||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base url setting.
|
|
||||||
"targetLanguage": "English", // Target language setting.
|
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
|
||||||
}, // Ai setting.
|
|
||||||
"media": {
|
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
|
||||||
"generateImage": true, // Generate image setting. Values: true | false
|
|
||||||
"imageType": "static", // Image type setting.
|
|
||||||
"imageFormat": "jpg", // Image format setting.
|
|
||||||
"imageQuality": 92, // Image quality setting.
|
|
||||||
"animatedFps": 10, // Animated fps setting.
|
|
||||||
"animatedMaxWidth": 640, // Animated max width setting.
|
|
||||||
"animatedCrf": 35, // Animated crf setting.
|
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
|
||||||
"maxMediaDuration": 30, // Max media duration setting.
|
|
||||||
}, // Media setting.
|
|
||||||
"behavior": {
|
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
|
||||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
|
||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
|
||||||
"notificationType": "osd", // Notification type setting.
|
|
||||||
"autoUpdateNewCards": true, // Automatically update newly added cards. Values: true | false
|
|
||||||
}, // Behavior setting.
|
|
||||||
"nPlusOne": {
|
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
|
||||||
"matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface
|
|
||||||
"decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names.
|
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
|
||||||
"nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight.
|
|
||||||
"knownWord": "#a6da95", // Color used for legacy known-word highlights.
|
|
||||||
}, // N plus one setting.
|
|
||||||
"metadata": {
|
|
||||||
"pattern": "[SubMiner] %f (%t)", // Pattern setting.
|
|
||||||
}, // Metadata setting.
|
|
||||||
"isLapis": {
|
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
|
||||||
"sentenceCardModel": "Japanese sentences", // Sentence card model setting.
|
|
||||||
}, // Is lapis setting.
|
|
||||||
"isKiku": {
|
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
|
||||||
"deleteDuplicateInAuto": true, // Delete duplicate in auto setting. Values: true | false
|
|
||||||
}, // Is kiku setting.
|
|
||||||
}, // Automatic Anki updates and media generation options.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Jimaku
|
|
||||||
// Jimaku API configuration and defaults.
|
|
||||||
// ==========================================
|
|
||||||
"jimaku": {
|
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
|
||||||
"maxEntryResults": 10, // Maximum Jimaku search results returned.
|
|
||||||
}, // Jimaku API configuration and defaults.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// YouTube Subtitle Generation
|
|
||||||
// Defaults for subminer YouTube subtitle extraction/transcription mode.
|
|
||||||
// ==========================================
|
|
||||||
"youtubeSubgen": {
|
|
||||||
"mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off
|
|
||||||
"whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine.
|
|
||||||
"whisperModel": "", // Path to whisper model used for fallback transcription.
|
|
||||||
"primarySubLanguages": ["ja", "jpn"], // Comma-separated primary subtitle language priority used by the launcher.
|
|
||||||
}, // Defaults for subminer YouTube subtitle extraction/transcription mode.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Anilist
|
|
||||||
// Anilist API credentials and update behavior.
|
|
||||||
// ==========================================
|
|
||||||
"anilist": {
|
|
||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
|
||||||
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
|
||||||
}, // Anilist API credentials and update behavior.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Jellyfin
|
|
||||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
|
||||||
// Access token is stored in local encrypted token storage after login/setup.
|
|
||||||
// jellyfin.accessToken remains an optional explicit override in config.
|
|
||||||
// ==========================================
|
|
||||||
"jellyfin": {
|
|
||||||
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
|
|
||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
|
||||||
"deviceId": "subminer", // Device id setting.
|
|
||||||
"clientName": "SubMiner", // Client name setting.
|
|
||||||
"clientVersion": "0.1.0", // Client version setting.
|
|
||||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
|
||||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
|
||||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
|
||||||
"autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false
|
|
||||||
"remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions.
|
|
||||||
"pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false
|
|
||||||
"iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons.
|
|
||||||
"directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false
|
|
||||||
"directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], // Container allowlist for direct play decisions.
|
|
||||||
"transcodeVideoCodec": "h264", // Preferred transcode video codec when direct play is unavailable.
|
|
||||||
}, // Optional Jellyfin integration for auth, browsing, and playback launch.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Discord Rich Presence
|
|
||||||
// Optional Discord Rich Presence activity card updates for current playback/study session.
|
|
||||||
// Uses official SubMiner Discord app assets for polished card visuals.
|
|
||||||
// ==========================================
|
|
||||||
"discordPresence": {
|
|
||||||
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
|
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
|
||||||
"debounceMs": 750, // Debounce delay used to collapse bursty presence updates.
|
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Immersion Tracking
|
|
||||||
// Enable/disable immersion tracking.
|
|
||||||
// Set dbPath to override the default sqlite database location.
|
|
||||||
// Policy tuning is available for queue, flush, and retention values.
|
|
||||||
// ==========================================
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false
|
|
||||||
"dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path.
|
|
||||||
"batchSize": 25, // Buffered telemetry/event writes per SQLite transaction.
|
|
||||||
"flushIntervalMs": 500, // Max delay before queue flush in milliseconds.
|
|
||||||
"queueCap": 1000, // In-memory write queue cap before overflow policy applies.
|
|
||||||
"payloadCapBytes": 256, // Max JSON payload size per event before truncation.
|
|
||||||
"maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks).
|
|
||||||
"retention": {
|
|
||||||
"eventsDays": 7, // Raw event retention window in days.
|
|
||||||
"telemetryDays": 30, // Telemetry retention window in days.
|
|
||||||
"dailyRollupsDays": 365, // Daily rollup retention window in days.
|
|
||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
|
||||||
"vacuumIntervalDays": 7, // Minimum days between VACUUM runs.
|
|
||||||
}, // Retention setting.
|
|
||||||
}, // Enable/disable immersion tracking.
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,54 +0,0 @@
|
|||||||
# TASK-100 Dead Code Report (2026-02-22)
|
|
||||||
|
|
||||||
## Baseline Verification
|
|
||||||
|
|
||||||
- `bun run build` -> PASS
|
|
||||||
- `bun run test:fast` -> PASS
|
|
||||||
|
|
||||||
## Discovery Commands
|
|
||||||
|
|
||||||
- `tsc --noEmit --noUnusedLocals --noUnusedParameters`
|
|
||||||
- `bunx ts-prune -p tsconfig.json`
|
|
||||||
|
|
||||||
## Triage
|
|
||||||
|
|
||||||
### Remove
|
|
||||||
|
|
||||||
- `src/anki-connect.ts` - removed unused `url` instance field.
|
|
||||||
- `src/anki-integration.ts` - removed unused wrappers: `poll`, `showProgressTick`, `refreshMiscInfoField`.
|
|
||||||
- `src/anki-integration/card-creation.ts` - removed unused `MediaGenerator` import.
|
|
||||||
- `src/anki-integration/ui-feedback.ts` - removed unused callback parameter in `withUpdateProgress`.
|
|
||||||
- `src/core/services/anki-jimaku-ipc.ts` - removed unused `JimakuDownloadQuery` import.
|
|
||||||
- `src/core/services/immersion-tracker-service.ts` - removed unused fields `lastMaintenanceMs`, `lastQueueWriteAtMs`; removed unused `runRollupMaintenance` wrapper.
|
|
||||||
- `src/core/services/ipc-command.ts` - removed unused `RuntimeOptionValue` import.
|
|
||||||
- `src/renderer/positioning/position-state.ts` - removed unused `ctx` parameter from `getPersistedOffset`.
|
|
||||||
- `src/tokenizers/index.ts` - removed unused exported helpers `getRegisteredTokenizerProviderIds`, `createTokenizerProvider`.
|
|
||||||
- `src/token-mergers/index.ts` - removed unused exported helpers `getRegisteredTokenMergerProviderIds`, `createTokenMergerProvider`.
|
|
||||||
- `src/core/utils/index.ts` - removed unused barrel re-exports `asBoolean`, `asFiniteNumber`, `asString`.
|
|
||||||
|
|
||||||
### Keep (intentional / out-of-scope)
|
|
||||||
|
|
||||||
- `src/main/runtime/composers/composer-contracts.type-test.ts` private `_` type aliases remain; they are compile-time contract assertions.
|
|
||||||
- `src/main.ts` large unused-import cluster from ongoing composer/runtime decomposition kept for separate focused task to avoid behavior risk.
|
|
||||||
- Broad `ts-prune` type-export findings in `src/types.ts` and multiple domain modules kept; many are declaration-surface exports and module-local false positives.
|
|
||||||
|
|
||||||
## Complexity Delta
|
|
||||||
|
|
||||||
- Removed 13 confirmed dead declarations/imports/helpers.
|
|
||||||
- Removed 4 unused exported entrypoints from provider registries/util barrel.
|
|
||||||
- `tsc --noEmit --noUnusedLocals --noUnusedParameters` diagnostics reduced to `39` lines; remaining diagnostics are concentrated in `src/main.ts` plus intentional type-test aliases.
|
|
||||||
|
|
||||||
## Regression Safety / Tests
|
|
||||||
|
|
||||||
- `bun test src/anki-integration.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/ipc.test.ts`
|
|
||||||
- partial pass; direct IPC test invocation hit Electron ESM test harness issue (`Export named 'ipcMain' not found`) unrelated to cleanup.
|
|
||||||
- Required task gates:
|
|
||||||
- `bun run build` -> PASS
|
|
||||||
- `bun run test:core:src` -> PASS
|
|
||||||
- `bun run test:config:src` -> PASS
|
|
||||||
- `bun run check:file-budgets` -> PASS (warning mode, no strict hotspot violations)
|
|
||||||
|
|
||||||
## Remaining Candidates
|
|
||||||
|
|
||||||
- Continue with dedicated `src/main.ts` dead-import cleanup once runtime composer migration settles.
|
|
||||||
- Revisit `ts-prune` findings with a declaration-aware filter to separate true dead exports from public API type surfaces.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Keyboard Shortcuts
|
|
||||||
|
|
||||||
All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it.
|
|
||||||
|
|
||||||
## Global Shortcuts
|
|
||||||
|
|
||||||
These work system-wide regardless of which window has focus.
|
|
||||||
|
|
||||||
| Shortcut | Action | Configurable |
|
|
||||||
| ------------- | ---------------------- | -------------------------------------- |
|
|
||||||
| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` |
|
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) |
|
|
||||||
|
|
||||||
::: tip
|
|
||||||
Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Mining Shortcuts
|
|
||||||
|
|
||||||
These work when the overlay window has focus.
|
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
|
||||||
| ------------------ | ----------------------------------------------- | --------------------------------------- |
|
|
||||||
| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` |
|
|
||||||
| `Ctrl/Cmd+Shift+S` | Mine multiple lines (press 1–9 to select count) | `shortcuts.mineSentenceMultiple` |
|
|
||||||
| `Ctrl/Cmd+C` | Copy current subtitle text | `shortcuts.copySubtitle` |
|
|
||||||
| `Ctrl/Cmd+Shift+C` | Copy multiple lines (press 1–9 to select count) | `shortcuts.copySubtitleMultiple` |
|
|
||||||
| `Ctrl/Cmd+V` | Update last Anki card from clipboard text | `shortcuts.updateLastCardFromClipboard` |
|
|
||||||
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
|
|
||||||
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
|
|
||||||
|
|
||||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine.
|
|
||||||
|
|
||||||
## Overlay Controls
|
|
||||||
|
|
||||||
These control playback and subtitle display. They require overlay window focus.
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| -------------------- | -------------------------------------------------- |
|
|
||||||
| `Space` | Toggle mpv pause |
|
|
||||||
| `J` | Cycle primary subtitle track |
|
|
||||||
| `Shift+J` | Cycle secondary subtitle track |
|
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
|
||||||
| `ArrowDown` | Seek backward 60 seconds |
|
|
||||||
| `Shift+H` | Jump to previous subtitle |
|
|
||||||
| `Shift+L` | Jump to next subtitle |
|
|
||||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
|
||||||
| `Shift+]` | Shift subtitle delay to next subtitle cue |
|
|
||||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
|
||||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
|
||||||
| `Q` | Quit mpv |
|
|
||||||
| `Ctrl+W` | Quit mpv |
|
|
||||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
|
||||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
|
||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover; resume after leaving subtitle area). Optional popup behavior: set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true` to keep playback paused while Yomitan popup is open.
|
|
||||||
|
|
||||||
When a Yomitan popup is open, SubMiner also provides popup control shortcuts:
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ----------- | ----------------------------------------------- |
|
|
||||||
| `J` | Scroll definitions down |
|
|
||||||
| `K` | Scroll definitions up |
|
|
||||||
| `ArrowDown` | Scroll definitions down |
|
|
||||||
| `ArrowUp` | Scroll definitions up |
|
|
||||||
| `M` | Mine/add selected term |
|
|
||||||
| `P` | Play selected term audio |
|
|
||||||
| `[` | Play previous available audio (selected source) |
|
|
||||||
| `]` | Play next available audio (selected source) |
|
|
||||||
|
|
||||||
## Keyboard-Driven Lookup Mode
|
|
||||||
|
|
||||||
These shortcuts are fixed (not configurable) and require overlay focus.
|
|
||||||
|
|
||||||
| Shortcut | Action |
|
|
||||||
| ------------------------------ | -------------------------------------------------------------------------------------------- |
|
|
||||||
| `Ctrl/Cmd+Shift+Y` | Toggle keyboard-driven token selection mode on/off |
|
|
||||||
| `Ctrl/Cmd+Y` | Toggle lookup popup for selected token (open when closed, close when open) |
|
|
||||||
| `ArrowLeft/Right`, `H`, or `L` | Move selected token (previous/next); if lookup is open, refresh definition for the new token |
|
|
||||||
|
|
||||||
Keyboard-driven mode draws a selection outline around the active token. Use `Ctrl/Cmd+Y` to open or close lookup for that token. While the popup is open, popup-local controls still work from the overlay (`J/K`, `ArrowUp/ArrowDown`, `M`, `P`, `[`, `]`) and focus is forced back to the overlay so token navigation can continue without clicking subtitle text again. Moving left/right past the start or end of the line jumps to the previous or next subtitle line and keeps playback paused if it was already paused.
|
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
|
||||||
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
|
||||||
|
|
||||||
## MPV Plugin Chords
|
|
||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
|
||||||
|
|
||||||
| Chord | Action |
|
|
||||||
| ----- | ------------------------ |
|
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
|
||||||
| `y-s` | Start overlay |
|
|
||||||
| `y-S` | Stop overlay |
|
|
||||||
| `y-t` | Toggle visible overlay |
|
|
||||||
| `y-o` | Open Yomitan settings |
|
|
||||||
| `y-r` | Restart overlay |
|
|
||||||
| `y-c` | Check overlay status |
|
|
||||||
|
|
||||||
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
|
|
||||||
|
|
||||||
## Drag-and-Drop
|
|
||||||
|
|
||||||
| Gesture | Action |
|
|
||||||
| ------------------------- | ------------------------------------------------ |
|
|
||||||
| Drop file(s) onto overlay | Replace current mpv playlist with dropped files |
|
|
||||||
| `Shift` + drop file(s) | Append all dropped files to current mpv playlist |
|
|
||||||
|
|
||||||
## Customizing Shortcuts
|
|
||||||
|
|
||||||
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"shortcuts": {
|
|
||||||
"mineSentence": "CommandOrControl+S",
|
|
||||||
"copySubtitle": "CommandOrControl+C",
|
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O",
|
|
||||||
"openJimaku": null, // disabled
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `keybindings` array overrides or extends the overlay's built-in key handling for mpv commands:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"keybindings": [
|
|
||||||
{ "key": "f", "command": ["cycle", "fullscreen"] },
|
|
||||||
{ "key": "m", "command": ["cycle", "mute"] },
|
|
||||||
{ "key": "Space", "command": null }, // disable default Space → pause
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner.
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# Troubleshooting
|
|
||||||
|
|
||||||
Common issues and how to resolve them.
|
|
||||||
|
|
||||||
## MPV Connection
|
|
||||||
|
|
||||||
**Overlay starts but shows no subtitles**
|
|
||||||
|
|
||||||
SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive.
|
|
||||||
|
|
||||||
- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`.
|
|
||||||
- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpvSocketPath`).
|
|
||||||
- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required.
|
|
||||||
|
|
||||||
SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart.
|
|
||||||
|
|
||||||
## Logging and App Mode
|
|
||||||
|
|
||||||
- Default log output is `info`.
|
|
||||||
- Use `--log-level` for more/less output.
|
|
||||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
|
||||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
|
||||||
|
|
||||||
## Performance and Resource Impact
|
|
||||||
|
|
||||||
### At a glance
|
|
||||||
|
|
||||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
|
||||||
- Common spikes come from:
|
|
||||||
- first subtitle parse/tokenization bursts
|
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`, `whisper` fallback path)
|
|
||||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
|
||||||
|
|
||||||
### If playback feels sluggish
|
|
||||||
|
|
||||||
1. Reduce overlay workload:
|
|
||||||
|
|
||||||
- set secondary subtitles hidden:
|
|
||||||
- `secondarySub.defaultMode: "hidden"`
|
|
||||||
- disable optional enrichment:
|
|
||||||
- `subtitleStyle.enableJlpt: false`
|
|
||||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
|
||||||
|
|
||||||
2. Reduce rendering pressure:
|
|
||||||
|
|
||||||
- lower `subtitleStyle.fontSize`
|
|
||||||
- keep overlay complexity minimal during heavy CPU periods
|
|
||||||
|
|
||||||
3. Reduce media overhead:
|
|
||||||
|
|
||||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
|
||||||
- lower `ankiConnect.media.imageQuality`
|
|
||||||
- reduce `ankiConnect.media.maxMediaDuration`
|
|
||||||
|
|
||||||
4. Lower integration cost:
|
|
||||||
|
|
||||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
|
||||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
|
||||||
- prefer YouTube `--mode automatic` over `preprocess` on low-resource systems
|
|
||||||
|
|
||||||
### Practical low-impact profile
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"fontSize": 30,
|
|
||||||
"enableJlpt": false,
|
|
||||||
"frequencyDictionary": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secondarySub": {
|
|
||||||
"defaultMode": "hidden"
|
|
||||||
},
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"imageType": "static",
|
|
||||||
"imageQuality": 80,
|
|
||||||
"maxMediaDuration": 12
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### If usage is still high
|
|
||||||
|
|
||||||
- Confirm only one SubMiner instance is running.
|
|
||||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
|
||||||
- Use `info` logs by default; keep `debug` for targeted diagnosis.
|
|
||||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
|
||||||
|
|
||||||
**"Failed to parse MPV message"**
|
|
||||||
|
|
||||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless — SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
|
||||||
|
|
||||||
## AnkiConnect
|
|
||||||
|
|
||||||
**"AnkiConnect: unable to connect"**
|
|
||||||
|
|
||||||
SubMiner connects to the active Anki endpoint:
|
|
||||||
|
|
||||||
- `ankiConnect.url` (direct mode, default `http://127.0.0.1:8765`)
|
|
||||||
- `http://<ankiConnect.proxy.host>:<ankiConnect.proxy.port>` (proxy mode)
|
|
||||||
|
|
||||||
This error means the active endpoint is unavailable, or (in proxy mode) the proxy cannot reach `ankiConnect.proxy.upstreamUrl`.
|
|
||||||
|
|
||||||
- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki.
|
|
||||||
- Make sure Anki is running before you start mining.
|
|
||||||
- If you changed the AnkiConnect port, update `ankiConnect.url` (or `ankiConnect.proxy.upstreamUrl` if using proxy mode).
|
|
||||||
- If using external Yomitan/browser clients, confirm they point to your SubMiner proxy URL.
|
|
||||||
|
|
||||||
SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored".
|
|
||||||
|
|
||||||
**Cards are created but fields are empty**
|
|
||||||
|
|
||||||
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` — for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
|
||||||
|
|
||||||
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
|
||||||
|
|
||||||
**"Update failed" OSD message**
|
|
||||||
|
|
||||||
Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes:
|
|
||||||
|
|
||||||
- The card was deleted in Anki between creation and enrichment update.
|
|
||||||
- The note type changed and a mapped field no longer exists.
|
|
||||||
|
|
||||||
## Overlay
|
|
||||||
|
|
||||||
**Overlay does not appear**
|
|
||||||
|
|
||||||
- Confirm SubMiner is running: `SubMiner.AppImage --start` or check for the process.
|
|
||||||
- On Linux, the overlay requires a compositor. Hyprland and Sway are supported natively; X11 requires `xdotool` and `xwininfo`.
|
|
||||||
- On macOS, grant Accessibility permission to SubMiner in System Settings > Privacy & Security > Accessibility.
|
|
||||||
|
|
||||||
**Overlay appears but clicks pass through / cannot interact**
|
|
||||||
|
|
||||||
- On Linux, mouse passthrough can be unreliable — this is a known Electron/platform limitation. The overlay keeps pointer events enabled by default on Linux.
|
|
||||||
- On macOS/Windows, `setIgnoreMouseEvents` toggles automatically. If clicks stop working, toggle the overlay off and back on (`Alt+Shift+O`).
|
|
||||||
- Make sure you are hovering over the subtitle area — the overlay only becomes interactive when the cursor is over subtitle text.
|
|
||||||
|
|
||||||
**Overlay briefly freezes after a modal/runtime error**
|
|
||||||
|
|
||||||
- Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running.").
|
|
||||||
- Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback.
|
|
||||||
- If errors keep recurring, toggle the overlay's DevTools using overlay chord `y` then `d` (or global `F12`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context.
|
|
||||||
|
|
||||||
**Overlay is on the wrong monitor or position**
|
|
||||||
|
|
||||||
SubMiner positions the overlay by tracking the mpv window. If tracking fails:
|
|
||||||
|
|
||||||
- Hyprland: Ensure `hyprctl` is available.
|
|
||||||
- Sway: Ensure `swaymsg` is available.
|
|
||||||
- X11: Ensure `xdotool` and `xwininfo` are installed.
|
|
||||||
|
|
||||||
If the overlay position is slightly off, right-click and drag on subtitle text to fine-tune the overlay subtitle offset.
|
|
||||||
|
|
||||||
## Yomitan
|
|
||||||
|
|
||||||
**"Yomitan extension not found in any search path"**
|
|
||||||
|
|
||||||
SubMiner bundles Yomitan and searches for it in these locations (in order):
|
|
||||||
|
|
||||||
1. `vendor/yomitan` (relative to executable)
|
|
||||||
2. `<resources>/yomitan` (Electron resources path)
|
|
||||||
3. `/usr/share/SubMiner/yomitan`
|
|
||||||
4. `~/.config/SubMiner/extensions/yomitan`
|
|
||||||
|
|
||||||
If you installed from the AppImage and see this error, the package may be incomplete. Re-download the AppImage or place the Yomitan extension manually in `~/.config/SubMiner/extensions/yomitan`.
|
|
||||||
|
|
||||||
**Yomitan popup does not appear when clicking words**
|
|
||||||
|
|
||||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
|
||||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
|
|
||||||
- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
|
|
||||||
|
|
||||||
## MeCab / Tokenization
|
|
||||||
|
|
||||||
**"MeCab not found on system"**
|
|
||||||
|
|
||||||
This is informational, not an error. SubMiner tokenization is driven by Yomitan's internal parser. MeCab availability checks may still run for auxiliary token metadata, but MeCab is not used as a tokenization fallback path.
|
|
||||||
|
|
||||||
To install MeCab:
|
|
||||||
|
|
||||||
- **Arch Linux**: `sudo pacman -S mecab mecab-ipadic`
|
|
||||||
- **Ubuntu/Debian**: `sudo apt install mecab libmecab-dev mecab-ipadic-utf8`
|
|
||||||
- **macOS**: `brew install mecab mecab-ipadic`
|
|
||||||
|
|
||||||
**Words are not segmented correctly**
|
|
||||||
|
|
||||||
Japanese word boundaries depend on Yomitan parser output. If segmentation seems wrong:
|
|
||||||
|
|
||||||
- Verify Yomitan dictionaries are installed and active.
|
|
||||||
- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect.
|
|
||||||
|
|
||||||
## Media Generation
|
|
||||||
|
|
||||||
**"FFmpeg not found"**
|
|
||||||
|
|
||||||
SubMiner uses FFmpeg to extract audio clips and generate screenshots. Install it:
|
|
||||||
|
|
||||||
- **Arch Linux**: `sudo pacman -S ffmpeg`
|
|
||||||
- **Ubuntu/Debian**: `sudo apt install ffmpeg`
|
|
||||||
- **macOS**: `brew install ffmpeg`
|
|
||||||
|
|
||||||
Without FFmpeg, card creation still works but audio and image fields will be empty.
|
|
||||||
|
|
||||||
**Audio or screenshot generation hangs**
|
|
||||||
|
|
||||||
Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try:
|
|
||||||
|
|
||||||
- Using a local copy of the video file.
|
|
||||||
- Reducing `media.imageQuality` or switching from `avif` to `static` image type.
|
|
||||||
- Checking that `media.maxMediaDuration` is not set too high.
|
|
||||||
|
|
||||||
## Shortcuts
|
|
||||||
|
|
||||||
**"Failed to register global shortcut"**
|
|
||||||
|
|
||||||
Global shortcuts (`Alt+Shift+O`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings.
|
|
||||||
|
|
||||||
- Check your DE/WM keybinding settings for conflicts.
|
|
||||||
- Change the shortcut in your config under `shortcuts.toggleVisibleOverlayGlobal`.
|
|
||||||
- On Wayland, global shortcut registration has limitations depending on the compositor.
|
|
||||||
|
|
||||||
**Overlay keybindings not working**
|
|
||||||
|
|
||||||
Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use the global shortcut to toggle it to give it focus.
|
|
||||||
|
|
||||||
## Subtitle Timing
|
|
||||||
|
|
||||||
**"Subtitle timing not found; copy again while playing"**
|
|
||||||
|
|
||||||
This OSD message appears when you try to mine a sentence but SubMiner has no timing data for the current subtitle. Causes:
|
|
||||||
|
|
||||||
- The video is paused and no subtitle has been received yet.
|
|
||||||
- The subtitle track changed and timing data was cleared.
|
|
||||||
- You are using an external subtitle file that mpv has not fully loaded.
|
|
||||||
|
|
||||||
Resume playback and wait for the next subtitle to appear, then try mining again.
|
|
||||||
|
|
||||||
## Subtitle Sync (Subsync)
|
|
||||||
|
|
||||||
**"Configured alass executable not found"**
|
|
||||||
|
|
||||||
Install alass or configure the path:
|
|
||||||
|
|
||||||
- **Arch Linux (AUR)**: `yay -S alass-git`
|
|
||||||
- Set the path: `subsync.alass_path` in your config.
|
|
||||||
|
|
||||||
**"Subtitle synchronization failed"**
|
|
||||||
|
|
||||||
SubMiner tries alass first, then falls back to ffsubsync. If both fail:
|
|
||||||
|
|
||||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
|
||||||
- Try running the sync tool manually to see detailed error output.
|
|
||||||
|
|
||||||
## Jimaku
|
|
||||||
|
|
||||||
**"Jimaku request failed" or HTTP 429**
|
|
||||||
|
|
||||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
|
||||||
|
|
||||||
## Platform-Specific
|
|
||||||
|
|
||||||
### Linux
|
|
||||||
|
|
||||||
- **Wayland (Hyprland/Sway)**: Window tracking uses compositor-specific commands. If `hyprctl` or `swaymsg` are not on `PATH`, tracking will fail silently.
|
|
||||||
- **X11**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position.
|
|
||||||
- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath.
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
|
|
||||||
- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust subtitle offset by right-click dragging subtitle text.
|
|
||||||
- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app`
|
|
||||||
232
docs/usage.md
@@ -1,232 +0,0 @@
|
|||||||
# Usage
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
|
||||||
> See [Yomitan setup](#yomitan-setup) for details.
|
|
||||||
|
|
||||||
There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin:
|
|
||||||
|
|
||||||
| Approach | Best For |
|
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. With default plugin settings, overlay auto-starts visible and playback resumes after annotation readiness. |
|
|
||||||
| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control overlay visibility. Requires `--input-ipc-server=/tmp/subminer-socket`. |
|
|
||||||
|
|
||||||
You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow.
|
|
||||||
|
|
||||||
`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer video.mkv`.
|
|
||||||
|
|
||||||
## Live Config Reload
|
|
||||||
|
|
||||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
|
||||||
|
|
||||||
Live-updated settings:
|
|
||||||
|
|
||||||
- `subtitleStyle`
|
|
||||||
- `keybindings`
|
|
||||||
- `shortcuts`
|
|
||||||
- `secondarySub.defaultMode`
|
|
||||||
- `ankiConnect.ai`
|
|
||||||
|
|
||||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
|
||||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Browse and play videos
|
|
||||||
subminer # Current directory (uses fzf)
|
|
||||||
subminer -R # Use rofi instead of fzf
|
|
||||||
subminer -d ~/Videos # Specific directory
|
|
||||||
subminer -r -d ~/Anime # Recursive search
|
|
||||||
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
|
|
||||||
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
|
|
||||||
subminer -S video.mkv # Same as above via --start-overlay
|
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
|
||||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
|
||||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
|
||||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
|
||||||
|
|
||||||
# Options
|
|
||||||
subminer -T video.mkv # Disable texthooker server
|
|
||||||
subminer -b x11 video.mkv # Force X11 backend
|
|
||||||
subminer video.mkv # Uses mpv profile "subminer" by default
|
|
||||||
subminer -p gpu-hq video.mkv # Override mpv profile
|
|
||||||
subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
|
||||||
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
|
||||||
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
|
||||||
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
|
||||||
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
|
||||||
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
|
||||||
subminer doctor # Dependency + config + socket diagnostics
|
|
||||||
subminer config path # Print active config path
|
|
||||||
subminer config show # Print active config contents
|
|
||||||
subminer mpv socket # Print active mpv socket path
|
|
||||||
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
|
||||||
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
|
||||||
subminer texthooker # Launch texthooker-only mode
|
|
||||||
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow)
|
|
||||||
subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directory shortcut
|
|
||||||
subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback
|
|
||||||
|
|
||||||
# Direct AppImage control
|
|
||||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
|
||||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
|
||||||
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
|
||||||
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
|
||||||
SubMiner.AppImage --settings # Open Yomitan settings
|
|
||||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
|
||||||
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
|
||||||
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
|
||||||
SubMiner.AppImage --jellyfin-libraries
|
|
||||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
|
||||||
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow)
|
|
||||||
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
|
||||||
SubMiner.AppImage --help # Show all options
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging and App Mode
|
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
|
||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`.
|
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
|
||||||
- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence.
|
|
||||||
Launcher pass-through commands also support `--password-store=<backend>` and forward it to the app when present.
|
|
||||||
Override with e.g. `--password-store=basic_text`.
|
|
||||||
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
|
||||||
|
|
||||||
### Launcher Subcommands
|
|
||||||
|
|
||||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
|
||||||
- `subminer yt` / `subminer youtube`: YouTube-focused shorthand flags (`-o`, `-m`).
|
|
||||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
|
||||||
- `subminer config`: config helpers (`path`, `show`).
|
|
||||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
|
||||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
|
|
||||||
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
|
||||||
- Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`).
|
|
||||||
|
|
||||||
Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`).
|
|
||||||
Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentionally rejected.
|
|
||||||
|
|
||||||
### MPV Profile Example (mpv.conf)
|
|
||||||
|
|
||||||
`subminer` passes the following MPV options directly on launch by default:
|
|
||||||
|
|
||||||
- `--input-ipc-server=/tmp/subminer-socket` (or your configured socket path)
|
|
||||||
- `--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us`
|
|
||||||
- `--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us`
|
|
||||||
- `--sub-auto=fuzzy`
|
|
||||||
- `--sub-file-paths=.;subs;subtitles`
|
|
||||||
- `--sid=auto`
|
|
||||||
- `--secondary-sid=auto`
|
|
||||||
- `--secondary-sub-visibility=no`
|
|
||||||
|
|
||||||
You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p <profile> ...`):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[subminer]
|
|
||||||
# IPC socket (must match SubMiner config)
|
|
||||||
input-ipc-server=/tmp/subminer-socket
|
|
||||||
|
|
||||||
# Prefer JP/EN audio + subtitle language variants
|
|
||||||
alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us
|
|
||||||
slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us
|
|
||||||
|
|
||||||
# Auto-load external subtitles
|
|
||||||
sub-auto=fuzzy
|
|
||||||
sub-file-paths=.;subs;subtitles
|
|
||||||
|
|
||||||
# Select primary + secondary subtitle tracks automatically
|
|
||||||
sid=auto
|
|
||||||
secondary-sid=auto
|
|
||||||
secondary-sub-visibility=no
|
|
||||||
```
|
|
||||||
|
|
||||||
`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead.
|
|
||||||
|
|
||||||
### Yomitan setup
|
|
||||||
|
|
||||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
|
||||||
|
|
||||||
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
|
|
||||||
|
|
||||||
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
|
||||||
|
|
||||||
### YouTube Playback
|
|
||||||
|
|
||||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably.
|
|
||||||
- `subminer` supports three subtitle-generation modes for YouTube URLs:
|
|
||||||
- `automatic` (default): starts playback immediately, generates subtitles in the background, and loads them into mpv when ready.
|
|
||||||
- `preprocess`: generates subtitles first, then starts playback with generated `.srt` files attached.
|
|
||||||
- `off`: disables launcher generation and leaves subtitle handling to mpv/yt-dlp.
|
|
||||||
- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
|
||||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
|
||||||
- `subminer` prefers subtitle tracks from yt-dlp first, then falls back to local `whisper.cpp` (`whisper-cli`) when tracks are missing.
|
|
||||||
- Whisper translation fallback currently only supports English secondary targets; non-English secondary targets rely on yt-dlp subtitle availability.
|
|
||||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables.
|
|
||||||
|
|
||||||
## Keybindings
|
|
||||||
|
|
||||||
### Global Shortcuts
|
|
||||||
|
|
||||||
| Keybind | Action |
|
|
||||||
| ------------- | ---------------------- |
|
|
||||||
| `Alt+Shift+O` | Toggle visible overlay |
|
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
|
||||||
|
|
||||||
`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config.
|
|
||||||
|
|
||||||
### Overlay Controls (Configurable)
|
|
||||||
|
|
||||||
| Input | Action |
|
|
||||||
| -------------------- | -------------------------------------------------- |
|
|
||||||
| `Space` | Toggle MPV pause |
|
|
||||||
| `ArrowRight` | Seek forward 5 seconds |
|
|
||||||
| `ArrowLeft` | Seek backward 5 seconds |
|
|
||||||
| `ArrowUp` | Seek forward 60 seconds |
|
|
||||||
| `ArrowDown` | Seek backward 60 seconds |
|
|
||||||
| `Shift+H` | Jump to previous subtitle |
|
|
||||||
| `Shift+L` | Jump to next subtitle |
|
|
||||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
|
||||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
|
||||||
| `Q` | Quit mpv |
|
|
||||||
| `Ctrl+W` | Quit mpv |
|
|
||||||
| `Right-click` | Toggle MPV pause (outside subtitle area) |
|
|
||||||
| `Right-click + drag` | Move subtitle position (on subtitle) |
|
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist |
|
|
||||||
|
|
||||||
These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization.
|
|
||||||
|
|
||||||
By default, hovering over subtitle text pauses mpv playback. Playback resumes as soon as the cursor leaves subtitle text. Set `subtitleStyle.autoPauseVideoOnHover` to `false` to disable this behavior.
|
|
||||||
|
|
||||||
If you want playback to stay paused while a Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup` to `true`. When enabled, SubMiner auto-resumes on popup close only if SubMiner paused playback for that popup.
|
|
||||||
|
|
||||||
Keyboard-driven lookup mode is available with fixed shortcuts: `Ctrl/Cmd+Shift+Y` toggles token-selection mode, `ArrowLeft/Right` (or `H/L`) moves the selected token, and `Ctrl/Cmd+Y` opens or closes lookup for that token.
|
|
||||||
|
|
||||||
If the Yomitan popup is open, you can control it directly from the overlay without moving focus into the popup: `J/K` or `ArrowUp/ArrowDown` scroll definitions, `M` mines/adds the selected term, `P` plays term audio, `[` plays the previous available audio, and `]` plays the next available audio in the selected source. While lookup stays open, `ArrowLeft/Right` (or `H/L`) moves to the previous or next token and refreshes the definition for the new token. If you move past the start or end of the current subtitle line, SubMiner jumps to the previous or next subtitle line, moves the selector to the edge token on that line, and keeps playback paused if it was already paused.
|
|
||||||
|
|
||||||
### Drag-and-drop Queueing
|
|
||||||
|
|
||||||
- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder).
|
|
||||||
- Hold `Shift` while dropping to append all dropped files to the current MPV playlist.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. MPV runs with an IPC socket at `/tmp/subminer-socket`
|
|
||||||
2. The overlay connects and subscribes to subtitle changes
|
|
||||||
3. Subtitles are tokenized with Yomitan's internal parser
|
|
||||||
4. Words are displayed as clickable spans
|
|
||||||
5. Clicking a word triggers Yomitan popup for dictionary lookup
|
|
||||||
6. Texthooker server runs at `http://127.0.0.1:5174` for external tools
|
|
||||||
@@ -4,6 +4,7 @@ import { parseArgs } from '../config.js';
|
|||||||
import type { ProcessAdapter } from '../process-adapter.js';
|
import type { ProcessAdapter } from '../process-adapter.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runConfigCommand } from './config-command.js';
|
import { runConfigCommand } from './config-command.js';
|
||||||
|
import { runDictionaryCommand } from './dictionary-command.js';
|
||||||
import { runDoctorCommand } from './doctor-command.js';
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||||
|
|
||||||
@@ -94,3 +95,23 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
|||||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.dictionary = true;
|
||||||
|
context.args.dictionaryTarget = '/tmp/anime';
|
||||||
|
const forwarded: string[][] = [];
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runDictionaryCommand(context, {
|
||||||
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
|
forwarded.push(appArgs);
|
||||||
|
throw new ExitSignal(0);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||||
|
});
|
||||||
|
|||||||
31
launcher/commands/dictionary-command.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
interface DictionaryCommandDeps {
|
||||||
|
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: DictionaryCommandDeps = {
|
||||||
|
runAppCommandWithInherit,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runDictionaryCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: DictionaryCommandDeps = defaultDeps,
|
||||||
|
): boolean {
|
||||||
|
const { args, appPath } = context;
|
||||||
|
if (!args.dictionary || !appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwarded = ['--dictionary'];
|
||||||
|
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
|
||||||
|
forwarded.push('--dictionary-target', args.dictionaryTarget);
|
||||||
|
}
|
||||||
|
if (args.logLevel !== 'info') {
|
||||||
|
forwarded.push('--log-level', args.logLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ test('launcher root help lists subcommands', () => {
|
|||||||
assert.match(output, /doctor/);
|
assert.match(output, /doctor/);
|
||||||
assert.match(output, /config/);
|
assert.match(output, /config/);
|
||||||
assert.match(output, /mpv/);
|
assert.match(output, /mpv/);
|
||||||
|
assert.match(output, /dictionary\|dict/);
|
||||||
assert.match(output, /texthooker/);
|
assert.match(output, /texthooker/);
|
||||||
assert.match(output, /app\|bin/);
|
assert.match(output, /app\|bin/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { fail } from '../log.js';
|
import { fail } from '../log.js';
|
||||||
import type {
|
import type {
|
||||||
Args,
|
Args,
|
||||||
@@ -68,6 +69,27 @@ function parseBackend(value: string): Backend {
|
|||||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDictionaryTarget(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
fail('Dictionary target path is required.');
|
||||||
|
}
|
||||||
|
if (isUrlTarget(trimmed)) {
|
||||||
|
fail('Dictionary target must be a local file or directory path, not a URL.');
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(resolvePathMaybe(trimmed));
|
||||||
|
let stat: fs.Stats | null = null;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(resolved);
|
||||||
|
} catch {
|
||||||
|
stat = null;
|
||||||
|
}
|
||||||
|
if (!stat || (!stat.isFile() && !stat.isDirectory())) {
|
||||||
|
fail(`Dictionary target path must be an existing file or directory: ${trimmed}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
||||||
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
||||||
const defaultMode: YoutubeSubgenMode =
|
const defaultMode: YoutubeSubgenMode =
|
||||||
@@ -114,6 +136,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinDiscovery: false,
|
jellyfinDiscovery: false,
|
||||||
|
dictionary: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
@@ -170,6 +193,10 @@ export function applyRootOptionsToArgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||||
|
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||||
|
if (invocations.dictionaryTarget) {
|
||||||
|
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||||
|
}
|
||||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||||
|
|
||||||
@@ -230,6 +257,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
|
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invocations.dictionaryLogLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||||
if (invocations.texthookerLogLevel)
|
if (invocations.texthookerLogLevel)
|
||||||
parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel);
|
parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel);
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export interface CliInvocations {
|
|||||||
configInvocation: CommandActionInvocation | null;
|
configInvocation: CommandActionInvocation | null;
|
||||||
mpvInvocation: CommandActionInvocation | null;
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
appInvocation: { appArgs: string[] } | null;
|
appInvocation: { appArgs: string[] } | null;
|
||||||
|
dictionaryTriggered: boolean;
|
||||||
|
dictionaryTarget: string | null;
|
||||||
|
dictionaryLogLevel: string | null;
|
||||||
doctorTriggered: boolean;
|
doctorTriggered: boolean;
|
||||||
doctorLogLevel: string | null;
|
doctorLogLevel: string | null;
|
||||||
texthookerTriggered: boolean;
|
texthookerTriggered: boolean;
|
||||||
@@ -81,6 +84,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'doctor',
|
'doctor',
|
||||||
'config',
|
'config',
|
||||||
'mpv',
|
'mpv',
|
||||||
|
'dictionary',
|
||||||
|
'dict',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
'app',
|
'app',
|
||||||
'bin',
|
'bin',
|
||||||
@@ -128,6 +133,9 @@ export function parseCliPrograms(
|
|||||||
let configInvocation: CommandActionInvocation | null = null;
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
let mpvInvocation: CommandActionInvocation | null = null;
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
|
let dictionaryTriggered = false;
|
||||||
|
let dictionaryTarget: string | null = null;
|
||||||
|
let dictionaryLogLevel: string | null = null;
|
||||||
let doctorLogLevel: string | null = null;
|
let doctorLogLevel: string | null = null;
|
||||||
let texthookerLogLevel: string | null = null;
|
let texthookerLogLevel: string | null = null;
|
||||||
let doctorTriggered = false;
|
let doctorTriggered = false;
|
||||||
@@ -214,6 +222,18 @@ export function parseCliPrograms(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('dictionary')
|
||||||
|
.alias('dict')
|
||||||
|
.description('Generate character dictionary ZIP from a file or directory target')
|
||||||
|
.argument('<target>', 'Video file path or anime directory path')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((target: string, options: Record<string, unknown>) => {
|
||||||
|
dictionaryTriggered = true;
|
||||||
|
dictionaryTarget = target;
|
||||||
|
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.description('Run dependency and environment checks')
|
.description('Run dependency and environment checks')
|
||||||
@@ -289,6 +309,9 @@ export function parseCliPrograms(
|
|||||||
configInvocation,
|
configInvocation,
|
||||||
mpvInvocation,
|
mpvInvocation,
|
||||||
appInvocation,
|
appInvocation,
|
||||||
|
dictionaryTriggered,
|
||||||
|
dictionaryTarget,
|
||||||
|
dictionaryLogLevel,
|
||||||
doctorTriggered,
|
doctorTriggered,
|
||||||
doctorLogLevel,
|
doctorLogLevel,
|
||||||
texthookerTriggered,
|
texthookerTriggered,
|
||||||
|
|||||||
@@ -162,6 +162,35 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
|
};
|
||||||
|
const targetPath = path.join(root, 'anime-folder');
|
||||||
|
fs.mkdirSync(targetPath, { recursive: true });
|
||||||
|
const result = runLauncher(['dictionary', targetPath], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(capturePath, 'utf8'),
|
||||||
|
`--dictionary\n--dictionary-target\n${targetPath}\n`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { runDoctorCommand } from './commands/doctor-command.js';
|
|||||||
import { runConfigCommand } from './commands/config-command.js';
|
import { runConfigCommand } from './commands/config-command.js';
|
||||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||||
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||||
|
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
|
|
||||||
@@ -90,6 +91,10 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runDictionaryCommand(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (await runJellyfinCommand(appContext)) {
|
if (await runJellyfinCommand(appContext)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinDiscovery: false,
|
jellyfinDiscovery: false,
|
||||||
|
dictionary: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
|
|||||||
@@ -50,3 +50,11 @@ test('parseArgs maps mpv idle action', () => {
|
|||||||
assert.equal(parsed.mpvIdle, true);
|
assert.equal(parsed.mpvIdle, true);
|
||||||
assert.equal(parsed.mpvStatus, false);
|
assert.equal(parsed.mpvStatus, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps dictionary command and log-level override', () => {
|
||||||
|
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.dictionary, true);
|
||||||
|
assert.equal(parsed.dictionaryTarget, process.cwd());
|
||||||
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
|
});
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export interface Args {
|
|||||||
jellyfinLogout: boolean;
|
jellyfinLogout: boolean;
|
||||||
jellyfinPlay: boolean;
|
jellyfinPlay: boolean;
|
||||||
jellyfinDiscovery: boolean;
|
jellyfinDiscovery: boolean;
|
||||||
|
dictionary: boolean;
|
||||||
|
dictionaryTarget?: string;
|
||||||
doctor: boolean;
|
doctor: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
configShow: boolean;
|
configShow: boolean;
|
||||||
|
|||||||
@@ -11,10 +11,6 @@
|
|||||||
"test-yomitan-parser:electron": "bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
"test-yomitan-parser:electron": "bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
||||||
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
"build": "tsc && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev docs --host 0.0.0.0 --port 5173 --strictPort",
|
|
||||||
"docs:watch": "bunx concurrently -n docs,backlog \"bun run docs:dev\" \"backlog browser\"",
|
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs",
|
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
|
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
|
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts",
|
||||||
@@ -57,12 +53,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"mermaid": "^11.12.3",
|
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -72,8 +66,7 @@
|
|||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"vitepress": "^1.6.4"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.sudacode.SubMiner",
|
"appId": "com.sudacode.SubMiner",
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO="${REPO:-$HOME/projects/japanese/SubMiner}"
|
|
||||||
LOCK_FILE="${LOCK_FILE:-/tmp/subminer-doc-sweep.lock}"
|
|
||||||
STATE_FILE="${STATE_FILE:-/tmp/subminer-doc-sweep.state}"
|
|
||||||
LOG_FILE="${LOG_FILE:-$REPO/.codex-doc-sweep.log}"
|
|
||||||
TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-240}"
|
|
||||||
SUBAGENT_ROOT="${SUBAGENT_ROOT:-$REPO/docs/subagents}"
|
|
||||||
SUBAGENT_INDEX_FILE="${SUBAGENT_INDEX_FILE:-$SUBAGENT_ROOT/INDEX.md}"
|
|
||||||
SUBAGENT_COLLAB_FILE="${SUBAGENT_COLLAB_FILE:-$SUBAGENT_ROOT/collaboration.md}"
|
|
||||||
SUBAGENT_AGENTS_DIR="${SUBAGENT_AGENTS_DIR:-$SUBAGENT_ROOT/agents}"
|
|
||||||
LEGACY_SUBAGENT_FILE="${LEGACY_SUBAGENT_FILE:-$REPO/docs/subagent.md}"
|
|
||||||
AGENT_ID="${AGENT_ID:-docs-sweep}"
|
|
||||||
AGENT_ALIAS="${AGENT_ALIAS:-Docs Sweep}"
|
|
||||||
AGENT_MISSION="${AGENT_MISSION:-Docs drift cleanup and coordination updates}"
|
|
||||||
|
|
||||||
# Non-interactive agent command used to run the prompt.
|
|
||||||
# Example:
|
|
||||||
# AGENT_CMD='codex exec'
|
|
||||||
# AGENT_CMD='opencode run'
|
|
||||||
AGENT_CMD="${AGENT_CMD:-codex exec}"
|
|
||||||
AGENT_ID_SAFE="$(printf '%s' "$AGENT_ID" | tr -c 'A-Za-z0-9._-' '_')"
|
|
||||||
AGENT_FILE="${SUBAGENT_AGENTS_DIR}/${AGENT_ID_SAFE}.md"
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$LOCK_FILE")"
|
|
||||||
mkdir -p "$(dirname "$STATE_FILE")"
|
|
||||||
mkdir -p "$SUBAGENT_ROOT" "$SUBAGENT_AGENTS_DIR" "$SUBAGENT_ROOT/archive"
|
|
||||||
|
|
||||||
exec 9> "$LOCK_FILE"
|
|
||||||
if ! flock -n 9; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$REPO"
|
|
||||||
|
|
||||||
current_state="$({
|
|
||||||
git status --porcelain=v1
|
|
||||||
git ls-files --others --exclude-standard
|
|
||||||
} | sha256sum | cut -d' ' -f1)"
|
|
||||||
|
|
||||||
previous_state="$(cat "$STATE_FILE" 2> /dev/null || true)"
|
|
||||||
if [[ "$current_state" == "$previous_state" ]]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "$current_state" > "$STATE_FILE"
|
|
||||||
|
|
||||||
run_started_at="$(date -Is)"
|
|
||||||
run_started_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
echo "[RUN] [$run_started_at] docs sweep running (agent_id=$AGENT_ID alias=$AGENT_ALIAS)"
|
|
||||||
echo "[$run_started_at] state changed; starting docs sweep (agent_id=$AGENT_ID alias=$AGENT_ALIAS)" >> "$LOG_FILE"
|
|
||||||
|
|
||||||
if [[ ! -f "$SUBAGENT_INDEX_FILE" ]]; then
|
|
||||||
cat > "$SUBAGENT_INDEX_FILE" << 'EOF'
|
|
||||||
# Subagents Index
|
|
||||||
|
|
||||||
Read first. Keep concise.
|
|
||||||
|
|
||||||
| agent_id | alias | mission | status | file | last_update_utc |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$SUBAGENT_COLLAB_FILE" ]]; then
|
|
||||||
cat > "$SUBAGENT_COLLAB_FILE" << 'EOF'
|
|
||||||
# Subagents Collaboration
|
|
||||||
|
|
||||||
Shared notes. Append-only.
|
|
||||||
|
|
||||||
- [YYYY-MM-DDTHH:MM:SSZ] [agent_id|alias] note, question, dependency, conflict, decision.
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$AGENT_FILE" ]]; then
|
|
||||||
cat > "$AGENT_FILE" << EOF
|
|
||||||
# Agent: $AGENT_ID
|
|
||||||
|
|
||||||
- alias: $AGENT_ALIAS
|
|
||||||
- mission: $AGENT_MISSION
|
|
||||||
- status: planning
|
|
||||||
- branch: unknown
|
|
||||||
- started_at: $run_started_utc
|
|
||||||
- heartbeat_minutes: 20
|
|
||||||
|
|
||||||
## Current Work (newest first)
|
|
||||||
- [$run_started_utc] intent: initialize section
|
|
||||||
|
|
||||||
## Files Touched
|
|
||||||
- none yet
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
- none yet
|
|
||||||
|
|
||||||
## Open Questions / Blockers
|
|
||||||
- none
|
|
||||||
|
|
||||||
## Next Step
|
|
||||||
- continue run
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$LEGACY_SUBAGENT_FILE" ]]; then
|
|
||||||
echo "[WARN] [$run_started_at] legacy file exists; prefer sharded layout: $LEGACY_SUBAGENT_FILE" | tee -a "$LOG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -r -d '' PROMPT << EOF || true
|
|
||||||
Watch for in-flight refactors. If repo changes introduced drift, update only:
|
|
||||||
- README.md
|
|
||||||
- AGENTS.md
|
|
||||||
- docs/**/*.md
|
|
||||||
- config.example.jsonc
|
|
||||||
- docs/public/config.example.jsonc <-- generated automatically with make generate-example-config / bun run generate:config-example
|
|
||||||
- package.json scripts/config references (only if needed)
|
|
||||||
|
|
||||||
Coordination protocol:
|
|
||||||
- Read in order before edits:
|
|
||||||
1) \`$SUBAGENT_INDEX_FILE\`
|
|
||||||
2) \`$SUBAGENT_COLLAB_FILE\`
|
|
||||||
3) \`$AGENT_FILE\`
|
|
||||||
- Edit scope:
|
|
||||||
- MAY edit own file: \`$AGENT_FILE\`
|
|
||||||
- MAY append to collaboration: \`$SUBAGENT_COLLAB_FILE\`
|
|
||||||
- MAY update own row in index: \`$SUBAGENT_INDEX_FILE\`
|
|
||||||
- MUST NOT edit other agent files in \`$SUBAGENT_AGENTS_DIR\`
|
|
||||||
- Ensure own file has updated: alias, mission, status, branch, started_at, heartbeat_minutes.
|
|
||||||
- Add UTC ISO entries in "Current Work (newest first)" for intent/progress/handoff for this run.
|
|
||||||
- Keep own file sections current: Files Touched, assumptions, blockers, next step.
|
|
||||||
- Ensure index row for \`$AGENT_ID\` reflects alias/mission/status/file/last_update_utc.
|
|
||||||
- If file conflict/dependency seen, append note in collaboration.
|
|
||||||
|
|
||||||
Run metadata:
|
|
||||||
- run_started_at_utc: $run_started_utc
|
|
||||||
- repo: $REPO
|
|
||||||
- agent_id: $AGENT_ID
|
|
||||||
- agent_alias: $AGENT_ALIAS
|
|
||||||
- agent_file: $AGENT_FILE
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Keep edits minimal and accurate to current code.
|
|
||||||
- Do not commit.
|
|
||||||
- Do not push.
|
|
||||||
- If ambiguous, do not guess; skip and report uncertainty.
|
|
||||||
- Print concise summary with:
|
|
||||||
1) files changed + why
|
|
||||||
2) coordination updates made (\`$SUBAGENT_INDEX_FILE\`, \`$SUBAGENT_COLLAB_FILE\`, \`$AGENT_FILE\`)
|
|
||||||
3) open questions/blockers
|
|
||||||
EOF
|
|
||||||
|
|
||||||
quoted_prompt="$(printf '%q' "$PROMPT")"
|
|
||||||
|
|
||||||
job_status=0
|
|
||||||
if timeout "${TIMEOUT_SECONDS}s" bash -lc "$AGENT_CMD $quoted_prompt" >> "$LOG_FILE" 2>&1; then
|
|
||||||
run_finished_at="$(date -Is)"
|
|
||||||
echo "[OK] [$run_finished_at] docs sweep complete (agent_id=$AGENT_ID)"
|
|
||||||
echo "[$run_finished_at] docs sweep complete (agent_id=$AGENT_ID)" >> "$LOG_FILE"
|
|
||||||
else
|
|
||||||
run_failed_at="$(date -Is)"
|
|
||||||
exit_code=$?
|
|
||||||
job_status=$exit_code
|
|
||||||
echo "[FAIL] [$run_failed_at] docs sweep failed (exit $exit_code, agent_id=$AGENT_ID)"
|
|
||||||
echo "[$run_failed_at] docs sweep failed (exit $exit_code, agent_id=$AGENT_ID)" >> "$LOG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$job_status"
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
RUN_ONCE_SCRIPT="$SCRIPT_DIR/docs-sweep-once.sh"
|
|
||||||
INTERVAL_SECONDS="${INTERVAL_SECONDS:-300}"
|
|
||||||
REPO="${REPO:-$HOME/projects/japanese/SubMiner}"
|
|
||||||
LOG_FILE="${LOG_FILE:-$REPO/.codex-doc-sweep.log}"
|
|
||||||
SUBAGENT_ROOT="${SUBAGENT_ROOT:-$REPO/docs/subagents}"
|
|
||||||
SUBAGENT_INDEX_FILE="${SUBAGENT_INDEX_FILE:-$SUBAGENT_ROOT/INDEX.md}"
|
|
||||||
SUBAGENT_COLLAB_FILE="${SUBAGENT_COLLAB_FILE:-$SUBAGENT_ROOT/collaboration.md}"
|
|
||||||
SUBAGENT_AGENTS_DIR="${SUBAGENT_AGENTS_DIR:-$SUBAGENT_ROOT/agents}"
|
|
||||||
AGENT_ID="${AGENT_ID:-docs-sweep}"
|
|
||||||
AGENT_ID_SAFE="$(printf '%s' "$AGENT_ID" | tr -c 'A-Za-z0-9._-' '_')"
|
|
||||||
AGENT_FILE="${AGENT_FILE:-$SUBAGENT_AGENTS_DIR/${AGENT_ID_SAFE}.md}"
|
|
||||||
REPORT_WITH_CODEX=false
|
|
||||||
REPORT_TIMEOUT_SECONDS="${REPORT_TIMEOUT_SECONDS:-120}"
|
|
||||||
REPORT_AGENT_CMD="${REPORT_AGENT_CMD:-codex exec}"
|
|
||||||
|
|
||||||
if [[ ! -x "$RUN_ONCE_SCRIPT" ]]; then
|
|
||||||
echo "Missing executable: $RUN_ONCE_SCRIPT"
|
|
||||||
echo "Run: chmod +x scripts/docs-sweep-once.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat << 'EOF'
|
|
||||||
Usage: scripts/docs-sweep-watch.sh [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-r, --report One-off: summarize current log with Codex and exit.
|
|
||||||
-h, --help Show this help message.
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
AGENT_ID Stable agent id (default: docs-sweep)
|
|
||||||
AGENT_ALIAS Human label shown in logs/coordination (default: Docs Sweep)
|
|
||||||
AGENT_MISSION One-line focus for this run
|
|
||||||
SUBAGENT_ROOT Coordination root (default: docs/subagents)
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
trim_log_runs() {
|
|
||||||
# Keep only the last 50 docs-sweep runs in the shared log file.
|
|
||||||
if [[ ! -f "$LOG_FILE" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local keep_runs=50
|
|
||||||
local start_line
|
|
||||||
start_line="$(
|
|
||||||
awk -v max="$keep_runs" '
|
|
||||||
/state changed; starting docs sweep/ { lines[++count] = NR }
|
|
||||||
END {
|
|
||||||
if (count > max) print lines[count - max + 1]
|
|
||||||
else print 0
|
|
||||||
}
|
|
||||||
' "$LOG_FILE"
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [[ "$start_line" =~ ^[0-9]+$ ]] && (( start_line > 0 )); then
|
|
||||||
local tmp_file
|
|
||||||
tmp_file="$(mktemp "${LOG_FILE}.XXXXXX")"
|
|
||||||
tail -n +"$start_line" "$LOG_FILE" > "$tmp_file"
|
|
||||||
mv "$tmp_file" "$LOG_FILE"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_report() {
|
|
||||||
local has_log=false
|
|
||||||
local has_index=false
|
|
||||||
local has_collab=false
|
|
||||||
local has_agent_file=false
|
|
||||||
if [[ -s "$LOG_FILE" ]]; then
|
|
||||||
has_log=true
|
|
||||||
fi
|
|
||||||
if [[ -s "$SUBAGENT_INDEX_FILE" ]]; then
|
|
||||||
has_index=true
|
|
||||||
fi
|
|
||||||
if [[ -s "$SUBAGENT_COLLAB_FILE" ]]; then
|
|
||||||
has_collab=true
|
|
||||||
fi
|
|
||||||
if [[ -s "$AGENT_FILE" ]]; then
|
|
||||||
has_agent_file=true
|
|
||||||
fi
|
|
||||||
if [[ "$has_log" != "true" && "$has_index" != "true" && "$has_collab" != "true" && "$has_agent_file" != "true" ]]; then
|
|
||||||
echo "[REPORT] no inputs; missing/empty files:"
|
|
||||||
echo "[REPORT] - $LOG_FILE"
|
|
||||||
echo "[REPORT] - $SUBAGENT_INDEX_FILE"
|
|
||||||
echo "[REPORT] - $SUBAGENT_COLLAB_FILE"
|
|
||||||
echo "[REPORT] - $AGENT_FILE"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local report_prompt
|
|
||||||
read -r -d '' report_prompt << EOF || true
|
|
||||||
Summarize docs sweep state. Output:
|
|
||||||
- Changes made (short bullets; file-focused when possible)
|
|
||||||
- Agent coordination updates from sharded docs/subagents files
|
|
||||||
- Open questions / uncertainty
|
|
||||||
- Left undone / follow-up items
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
- Be concise.
|
|
||||||
- If uncertain, say uncertain.
|
|
||||||
Read these files directly if present:
|
|
||||||
$LOG_FILE
|
|
||||||
$SUBAGENT_INDEX_FILE
|
|
||||||
$SUBAGENT_COLLAB_FILE
|
|
||||||
$AGENT_FILE
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo "[REPORT] codex summary start"
|
|
||||||
local report_file
|
|
||||||
local report_stderr
|
|
||||||
report_file="$(mktemp /tmp/docs-sweep-report.XXXXXX)"
|
|
||||||
report_stderr="$(mktemp /tmp/docs-sweep-report-stderr.XXXXXX)"
|
|
||||||
(
|
|
||||||
cd "$REPO"
|
|
||||||
timeout "${REPORT_TIMEOUT_SECONDS}s" bash -lc "$REPORT_AGENT_CMD -o $(printf '%q' "$report_file") $(printf '%q' "$report_prompt")" > /dev/null 2> "$report_stderr"
|
|
||||||
)
|
|
||||||
local report_exit=$?
|
|
||||||
if (( report_exit != 0 )); then
|
|
||||||
echo "[REPORT] codex summary failed (exit $report_exit)"
|
|
||||||
cat "$report_stderr"
|
|
||||||
echo
|
|
||||||
echo "[REPORT] codex summary end"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [[ -s "$report_file" ]]; then
|
|
||||||
cat "$report_file"
|
|
||||||
else
|
|
||||||
echo "[REPORT] codex produced no final message"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
echo "[REPORT] codex summary end"
|
|
||||||
}
|
|
||||||
|
|
||||||
while (( $# > 0 )); do
|
|
||||||
case "$1" in
|
|
||||||
-r|--report)
|
|
||||||
REPORT_WITH_CODEX=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1"
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$REPORT_WITH_CODEX" == "true" ]]; then
|
|
||||||
trim_log_runs
|
|
||||||
run_report
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
stop_requested=false
|
|
||||||
trap 'stop_requested=true' INT TERM
|
|
||||||
|
|
||||||
echo "Starting docs sweep watcher (interval: ${INTERVAL_SECONDS}s, subagent_root: ${SUBAGENT_ROOT}). Press Ctrl+C to stop."
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
run_started_at="$(date -Is)"
|
|
||||||
echo "[RUN] [$run_started_at] docs sweep cycle running"
|
|
||||||
if "$RUN_ONCE_SCRIPT"; then
|
|
||||||
run_finished_at="$(date -Is)"
|
|
||||||
echo "[OK] [$run_finished_at] docs sweep cycle complete"
|
|
||||||
else
|
|
||||||
run_failed_at="$(date -Is)"
|
|
||||||
exit_code=$?
|
|
||||||
echo "[FAIL] [$run_failed_at] docs sweep cycle failed (exit $exit_code)"
|
|
||||||
fi
|
|
||||||
trim_log_runs
|
|
||||||
|
|
||||||
if [[ "$stop_requested" == "true" ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep "$INTERVAL_SECONDS" &
|
|
||||||
wait $!
|
|
||||||
|
|
||||||
if [[ "$stop_requested" == "true" ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Docs sweep watcher stopped."
|
|
||||||
@@ -121,6 +121,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
assert.equal(hasExplicitCommand(anilistRetryQueue), true);
|
||||||
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
assert.equal(shouldStartApp(anilistRetryQueue), false);
|
||||||
|
|
||||||
|
const dictionary = parseArgs(['--dictionary']);
|
||||||
|
assert.equal(dictionary.dictionary, true);
|
||||||
|
assert.equal(hasExplicitCommand(dictionary), true);
|
||||||
|
assert.equal(shouldStartApp(dictionary), true);
|
||||||
|
const dictionaryTarget = parseArgs(['--dictionary', '--dictionary-target', '/tmp/example.mkv']);
|
||||||
|
assert.equal(dictionaryTarget.dictionary, true);
|
||||||
|
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
|
||||||
|
|
||||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface CliArgs {
|
|||||||
anilistLogout: boolean;
|
anilistLogout: boolean;
|
||||||
anilistSetup: boolean;
|
anilistSetup: boolean;
|
||||||
anilistRetryQueue: boolean;
|
anilistRetryQueue: boolean;
|
||||||
|
dictionary: boolean;
|
||||||
|
dictionaryTarget?: string;
|
||||||
jellyfin: boolean;
|
jellyfin: boolean;
|
||||||
jellyfinLogin: boolean;
|
jellyfinLogin: boolean;
|
||||||
jellyfinLogout: boolean;
|
jellyfinLogout: boolean;
|
||||||
@@ -88,6 +90,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
|
dictionary: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
@@ -141,7 +144,14 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
else if (arg === '--anilist-logout') args.anilistLogout = true;
|
||||||
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
else if (arg === '--anilist-setup') args.anilistSetup = true;
|
||||||
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true;
|
||||||
else if (arg === '--jellyfin') args.jellyfin = true;
|
else if (arg === '--dictionary') args.dictionary = true;
|
||||||
|
else if (arg.startsWith('--dictionary-target=')) {
|
||||||
|
const value = arg.split('=', 2)[1];
|
||||||
|
if (value) args.dictionaryTarget = value;
|
||||||
|
} else if (arg === '--dictionary-target') {
|
||||||
|
const value = readValue(argv[i + 1]);
|
||||||
|
if (value) args.dictionaryTarget = value;
|
||||||
|
} else if (arg === '--jellyfin') args.jellyfin = true;
|
||||||
else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
|
else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
|
||||||
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
|
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
|
||||||
else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true;
|
else if (arg === '--jellyfin-libraries') args.jellyfinLibraries = true;
|
||||||
@@ -307,6 +317,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.anilistLogout ||
|
args.anilistLogout ||
|
||||||
args.anilistSetup ||
|
args.anilistSetup ||
|
||||||
args.anilistRetryQueue ||
|
args.anilistRetryQueue ||
|
||||||
|
args.dictionary ||
|
||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
args.jellyfinLogin ||
|
args.jellyfinLogin ||
|
||||||
args.jellyfinLogout ||
|
args.jellyfinLogout ||
|
||||||
@@ -340,6 +351,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.triggerSubsync ||
|
args.triggerSubsync ||
|
||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
|
args.dictionary ||
|
||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.texthooker
|
args.texthooker
|
||||||
@@ -376,6 +388,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.anilistLogout &&
|
!args.anilistLogout &&
|
||||||
!args.anilistSetup &&
|
!args.anilistSetup &&
|
||||||
!args.anilistRetryQueue &&
|
!args.anilistRetryQueue &&
|
||||||
|
!args.dictionary &&
|
||||||
!args.jellyfin &&
|
!args.jellyfin &&
|
||||||
!args.jellyfinLogin &&
|
!args.jellyfinLogin &&
|
||||||
!args.jellyfinLogout &&
|
!args.jellyfinLogout &&
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ test('printHelp includes configured texthooker port', () => {
|
|||||||
assert.match(output, /--refresh-known-words/);
|
assert.match(output, /--refresh-known-words/);
|
||||||
assert.match(output, /--anilist-status/);
|
assert.match(output, /--anilist-status/);
|
||||||
assert.match(output, /--anilist-retry-queue/);
|
assert.match(output, /--anilist-retry-queue/);
|
||||||
|
assert.match(output, /--dictionary/);
|
||||||
|
assert.match(output, /--dictionary-target/);
|
||||||
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
assert.match(output, /--jellyfin\s+Open Jellyfin setup window/);
|
||||||
assert.match(output, /--jellyfin-login/);
|
assert.match(output, /--jellyfin-login/);
|
||||||
assert.match(output, /--jellyfin-subtitles/);
|
assert.match(output, /--jellyfin-subtitles/);
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ ${B}AniList${R}
|
|||||||
--anilist-status Show token and retry queue status
|
--anilist-status Show token and retry queue status
|
||||||
--anilist-logout Clear stored AniList token
|
--anilist-logout Clear stored AniList token
|
||||||
--anilist-retry-queue Retry next queued update
|
--anilist-retry-queue Retry next queued update
|
||||||
|
--dictionary Generate character dictionary ZIP for current anime
|
||||||
|
--dictionary-target ${D}PATH${R} Override dictionary source path (file or directory)
|
||||||
|
|
||||||
${B}Jellyfin${R}
|
${B}Jellyfin${R}
|
||||||
--jellyfin Open Jellyfin setup window
|
--jellyfin Open Jellyfin setup window
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||||
assert.equal(config.anilist.enabled, false);
|
assert.equal(config.anilist.enabled, false);
|
||||||
|
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||||
|
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||||
|
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||||
|
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
|
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||||
@@ -298,6 +303,39 @@ test('parses anilist.enabled and warns for invalid value', () => {
|
|||||||
assert.equal(service.getConfig().anilist.enabled, true);
|
assert.equal(service.getConfig().anilist.enabled, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses anilist.characterDictionary config with clamping and enum validation', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"anilist": {
|
||||||
|
"characterDictionary": {
|
||||||
|
"enabled": true,
|
||||||
|
"refreshTtlHours": 0,
|
||||||
|
"maxLoaded": 1000,
|
||||||
|
"evictionPolicy": "remove",
|
||||||
|
"profileScope": "everywhere"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
const config = service.getConfig();
|
||||||
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
|
assert.equal(config.anilist.characterDictionary.enabled, true);
|
||||||
|
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
|
||||||
|
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
|
||||||
|
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
|
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'));
|
||||||
|
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'));
|
||||||
|
});
|
||||||
|
|
||||||
test('parses jellyfin remote control fields', () => {
|
test('parses jellyfin remote control fields', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -1292,6 +1330,7 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
assert.match(output, /"startupWarmups":/);
|
assert.match(output, /"startupWarmups":/);
|
||||||
assert.match(output, /"youtubeSubgen":/);
|
assert.match(output, /"youtubeSubgen":/);
|
||||||
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
anilist: {
|
anilist: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
|
characterDictionary: {
|
||||||
|
enabled: false,
|
||||||
|
refreshTtlHours: 168,
|
||||||
|
maxLoaded: 3,
|
||||||
|
evictionPolicy: 'delete',
|
||||||
|
profileScope: 'all',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
|
'anilist.characterDictionary.enabled',
|
||||||
'immersionTracking.enabled',
|
'immersionTracking.enabled',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||||
|
|||||||
@@ -135,6 +135,39 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'anilist.characterDictionary.enabled',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
|
||||||
|
description:
|
||||||
|
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'anilist.characterDictionary.refreshTtlHours',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
|
||||||
|
description: 'TTL in hours before refreshing the currently watched media dictionary.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'anilist.characterDictionary.maxLoaded',
|
||||||
|
kind: 'number',
|
||||||
|
defaultValue: defaultConfig.anilist.characterDictionary.maxLoaded,
|
||||||
|
description: 'Maximum number of auto-synced AniList dictionaries kept loaded at once.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'anilist.characterDictionary.evictionPolicy',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['disable', 'delete'],
|
||||||
|
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
|
||||||
|
description: 'Eviction behavior when maxLoaded is exceeded.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'anilist.characterDictionary.profileScope',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: ['all', 'active'],
|
||||||
|
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
|
||||||
|
description: 'Yomitan profile scope for dictionary enable/disable updates.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'jellyfin.enabled',
|
path: 'jellyfin.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Anilist',
|
title: 'Anilist',
|
||||||
description: ['Anilist API credentials and update behavior.'],
|
description: [
|
||||||
|
'Anilist API credentials and update behavior.',
|
||||||
|
'Includes optional auto-sync for per-media character dictionaries in bundled Yomitan.',
|
||||||
|
'Character dictionaries are keyed by AniList media ID (no season/franchise merge).',
|
||||||
|
],
|
||||||
key: 'anilist',
|
key: 'anilist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,115 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
'Expected string.',
|
'Expected string.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.anilist.characterDictionary)) {
|
||||||
|
const characterDictionary = src.anilist.characterDictionary;
|
||||||
|
|
||||||
|
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
|
||||||
|
if (dictionaryEnabled !== undefined) {
|
||||||
|
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
|
||||||
|
} else if (characterDictionary.enabled !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.enabled',
|
||||||
|
characterDictionary.enabled,
|
||||||
|
resolved.anilist.characterDictionary.enabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
|
||||||
|
if (refreshTtlHours !== undefined) {
|
||||||
|
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
|
||||||
|
if (normalized !== refreshTtlHours) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
|
characterDictionary.refreshTtlHours,
|
||||||
|
normalized,
|
||||||
|
'Out of range; clamped to 1..8760 hours.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolved.anilist.characterDictionary.refreshTtlHours = normalized;
|
||||||
|
} else if (characterDictionary.refreshTtlHours !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
|
characterDictionary.refreshTtlHours,
|
||||||
|
resolved.anilist.characterDictionary.refreshTtlHours,
|
||||||
|
'Expected number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLoaded = asNumber(characterDictionary.maxLoaded);
|
||||||
|
if (maxLoaded !== undefined) {
|
||||||
|
const normalized = Math.min(20, Math.max(1, Math.floor(maxLoaded)));
|
||||||
|
if (normalized !== maxLoaded) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.maxLoaded',
|
||||||
|
characterDictionary.maxLoaded,
|
||||||
|
normalized,
|
||||||
|
'Out of range; clamped to 1..20.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolved.anilist.characterDictionary.maxLoaded = normalized;
|
||||||
|
} else if (characterDictionary.maxLoaded !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.maxLoaded',
|
||||||
|
characterDictionary.maxLoaded,
|
||||||
|
resolved.anilist.characterDictionary.maxLoaded,
|
||||||
|
'Expected number.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const evictionPolicyRaw = asString(characterDictionary.evictionPolicy);
|
||||||
|
if (evictionPolicyRaw !== undefined) {
|
||||||
|
const evictionPolicy = evictionPolicyRaw.trim().toLowerCase();
|
||||||
|
if (evictionPolicy === 'disable' || evictionPolicy === 'delete') {
|
||||||
|
resolved.anilist.characterDictionary.evictionPolicy = evictionPolicy;
|
||||||
|
} else {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
|
characterDictionary.evictionPolicy,
|
||||||
|
resolved.anilist.characterDictionary.evictionPolicy,
|
||||||
|
"Expected one of: 'disable', 'delete'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (characterDictionary.evictionPolicy !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
|
characterDictionary.evictionPolicy,
|
||||||
|
resolved.anilist.characterDictionary.evictionPolicy,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileScopeRaw = asString(characterDictionary.profileScope);
|
||||||
|
if (profileScopeRaw !== undefined) {
|
||||||
|
const profileScope = profileScopeRaw.trim().toLowerCase();
|
||||||
|
if (profileScope === 'all' || profileScope === 'active') {
|
||||||
|
resolved.anilist.characterDictionary.profileScope = profileScope;
|
||||||
|
} else {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.profileScope',
|
||||||
|
characterDictionary.profileScope,
|
||||||
|
resolved.anilist.characterDictionary.profileScope,
|
||||||
|
"Expected one of: 'all', 'active'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (characterDictionary.profileScope !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary.profileScope',
|
||||||
|
characterDictionary.profileScope,
|
||||||
|
resolved.anilist.characterDictionary.profileScope,
|
||||||
|
'Expected string.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (src.anilist.characterDictionary !== undefined) {
|
||||||
|
warn(
|
||||||
|
'anilist.characterDictionary',
|
||||||
|
src.anilist.characterDictionary,
|
||||||
|
resolved.anilist.characterDictionary,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.jellyfin)) {
|
if (isObject(src.jellyfin)) {
|
||||||
|
|||||||
@@ -62,3 +62,31 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
|||||||
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
||||||
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('anilist character dictionary fields are parsed, clamped, and enum-validated', () => {
|
||||||
|
const { context, warnings } = createResolveContext({
|
||||||
|
anilist: {
|
||||||
|
characterDictionary: {
|
||||||
|
enabled: true,
|
||||||
|
refreshTtlHours: 0,
|
||||||
|
maxLoaded: 99,
|
||||||
|
evictionPolicy: 'purge' as never,
|
||||||
|
profileScope: 'global' as never,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
|
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
|
||||||
|
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
|
||||||
|
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
|
||||||
|
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
|
assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all');
|
||||||
|
|
||||||
|
const warnedPaths = warnings.map((warning) => warning.path);
|
||||||
|
assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours'));
|
||||||
|
assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded'));
|
||||||
|
assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy'));
|
||||||
|
assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
|
dictionary: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
|
dictionary: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
@@ -163,6 +164,13 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
calls.push('retryAnilistQueue');
|
calls.push('retryAnilistQueue');
|
||||||
return { ok: true, message: 'AniList retry processed.' };
|
return { ok: true, message: 'AniList retry processed.' };
|
||||||
},
|
},
|
||||||
|
generateCharacterDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/anilist-1.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 1,
|
||||||
|
mediaTitle: 'Test',
|
||||||
|
entryCount: 10,
|
||||||
|
}),
|
||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('runJellyfinCommand');
|
calls.push('runJellyfinCommand');
|
||||||
},
|
},
|
||||||
@@ -396,6 +404,52 @@ test('handleCliCommand runs AniList retry command', async () => {
|
|||||||
assert.ok(calls.includes('log:AniList retry processed.'));
|
assert.ok(calls.includes('log:AniList retry processed.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand runs dictionary generation command', async () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
hasMainWindow: () => false,
|
||||||
|
generateCharacterDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/anilist-9253.zip',
|
||||||
|
fromCache: true,
|
||||||
|
mediaId: 9253,
|
||||||
|
mediaTitle: 'STEINS;GATE',
|
||||||
|
entryCount: 314,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
handleCliCommand(makeArgs({ dictionary: true }), 'initial', deps);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.ok(calls.includes('log:Generating character dictionary for current anime...'));
|
||||||
|
assert.ok(
|
||||||
|
calls.includes('log:Character dictionary cache hit: AniList 9253 (STEINS;GATE), entries=314'),
|
||||||
|
);
|
||||||
|
assert.ok(calls.includes('log:Dictionary ZIP: /tmp/anilist-9253.zip'));
|
||||||
|
assert.ok(calls.includes('stopApp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCliCommand forwards --dictionary-target to dictionary runtime', async () => {
|
||||||
|
let receivedTarget: string | undefined;
|
||||||
|
const { deps } = createDeps({
|
||||||
|
generateCharacterDictionary: async (targetPath?: string) => {
|
||||||
|
receivedTarget = targetPath;
|
||||||
|
return {
|
||||||
|
zipPath: '/tmp/anilist-100.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 100,
|
||||||
|
mediaTitle: 'Test',
|
||||||
|
entryCount: 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleCliCommand(
|
||||||
|
makeArgs({ dictionary: true, dictionaryTarget: '/tmp/example-video.mkv' }),
|
||||||
|
'initial',
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(receivedTarget, '/tmp/example-video.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
|
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
|
||||||
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
|
const nonJellyfinArgs: Array<Partial<CliArgs>> = [
|
||||||
{ start: true },
|
{ start: true },
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export interface CliCommandServiceDeps {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>;
|
||||||
|
generateCharacterDictionary: (targetPath?: string) => Promise<{
|
||||||
|
zipPath: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
}>;
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
@@ -134,6 +141,15 @@ export interface CliCommandDepsRuntimeOptions {
|
|||||||
overlay: OverlayCliRuntime;
|
overlay: OverlayCliRuntime;
|
||||||
mining: MiningCliRuntime;
|
mining: MiningCliRuntime;
|
||||||
anilist: AnilistCliRuntime;
|
anilist: AnilistCliRuntime;
|
||||||
|
dictionary: {
|
||||||
|
generate: (targetPath?: string) => Promise<{
|
||||||
|
zipPath: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
openSetup: () => void;
|
openSetup: () => void;
|
||||||
runCommand: (args: CliArgs) => Promise<void>;
|
runCommand: (args: CliArgs) => Promise<void>;
|
||||||
@@ -202,6 +218,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
openJellyfinSetup: options.jellyfin.openSetup,
|
openJellyfinSetup: options.jellyfin.openSetup,
|
||||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||||
|
generateCharacterDictionary: options.dictionary.generate,
|
||||||
runJellyfinCommand: options.jellyfin.runCommand,
|
runJellyfinCommand: options.jellyfin.runCommand,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
@@ -239,50 +256,6 @@ export function handleCliCommand(
|
|||||||
deps.setLogLevel?.(args.logLevel);
|
deps.setLogLevel?.(args.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNonStartAction =
|
|
||||||
args.stop ||
|
|
||||||
args.toggle ||
|
|
||||||
args.toggleVisibleOverlay ||
|
|
||||||
args.settings ||
|
|
||||||
args.show ||
|
|
||||||
args.hide ||
|
|
||||||
args.showVisibleOverlay ||
|
|
||||||
args.hideVisibleOverlay ||
|
|
||||||
args.copySubtitle ||
|
|
||||||
args.copySubtitleMultiple ||
|
|
||||||
args.mineSentence ||
|
|
||||||
args.mineSentenceMultiple ||
|
|
||||||
args.updateLastCardFromClipboard ||
|
|
||||||
args.refreshKnownWords ||
|
|
||||||
args.toggleSecondarySub ||
|
|
||||||
args.triggerFieldGrouping ||
|
|
||||||
args.triggerSubsync ||
|
|
||||||
args.markAudioCard ||
|
|
||||||
args.openRuntimeOptions ||
|
|
||||||
args.anilistStatus ||
|
|
||||||
args.anilistLogout ||
|
|
||||||
args.anilistSetup ||
|
|
||||||
args.anilistRetryQueue ||
|
|
||||||
args.jellyfin ||
|
|
||||||
args.jellyfinLogin ||
|
|
||||||
args.jellyfinLogout ||
|
|
||||||
args.jellyfinLibraries ||
|
|
||||||
args.jellyfinItems ||
|
|
||||||
args.jellyfinSubtitles ||
|
|
||||||
args.jellyfinPlay ||
|
|
||||||
args.jellyfinRemoteAnnounce ||
|
|
||||||
args.texthooker ||
|
|
||||||
args.help;
|
|
||||||
const ignoreStartOnly =
|
|
||||||
source === 'second-instance' &&
|
|
||||||
args.start &&
|
|
||||||
!hasNonStartAction &&
|
|
||||||
deps.isOverlayRuntimeInitialized();
|
|
||||||
if (ignoreStartOnly) {
|
|
||||||
deps.log('Ignoring --start because SubMiner is already running.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
const shouldStart = args.start || args.toggle || args.toggleVisibleOverlay;
|
||||||
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
const needsOverlayRuntime = commandNeedsOverlayRuntime(args);
|
||||||
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
const shouldInitializeOverlayRuntime = needsOverlayRuntime || args.start;
|
||||||
@@ -402,6 +375,29 @@ export function handleCliCommand(
|
|||||||
} else if (args.jellyfin) {
|
} else if (args.jellyfin) {
|
||||||
deps.openJellyfinSetup();
|
deps.openJellyfinSetup();
|
||||||
deps.log('Opened Jellyfin setup flow.');
|
deps.log('Opened Jellyfin setup flow.');
|
||||||
|
} else if (args.dictionary) {
|
||||||
|
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||||
|
deps.log('Generating character dictionary for current anime...');
|
||||||
|
deps
|
||||||
|
.generateCharacterDictionary(args.dictionaryTarget)
|
||||||
|
.then((result) => {
|
||||||
|
const cacheLabel = result.fromCache ? 'cache hit' : 'generated';
|
||||||
|
deps.log(
|
||||||
|
`Character dictionary ${cacheLabel}: AniList ${result.mediaId} (${result.mediaTitle}), entries=${result.entryCount}`,
|
||||||
|
);
|
||||||
|
deps.log(`Dictionary ZIP: ${result.zipPath}`);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
deps.error('generateCharacterDictionary failed:', error);
|
||||||
|
deps.warn(
|
||||||
|
`Dictionary generation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (shouldStopAfterRun) {
|
||||||
|
deps.stopApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (args.anilistRetryQueue) {
|
} else if (args.anilistRetryQueue) {
|
||||||
const queueStatus = deps.getAnilistQueueStatus();
|
const queueStatus = deps.getAnilistQueueStatus();
|
||||||
deps.log(
|
deps.log(
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ export {
|
|||||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||||
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
|
export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime';
|
||||||
|
export {
|
||||||
|
deleteYomitanDictionaryByTitle,
|
||||||
|
getYomitanDictionaryInfo,
|
||||||
|
getYomitanSettingsFull,
|
||||||
|
importYomitanDictionaryFromZip,
|
||||||
|
removeYomitanDictionarySettings,
|
||||||
|
setYomitanSettingsFull,
|
||||||
|
upsertYomitanDictionarySettings,
|
||||||
|
} from './tokenizer/yomitan-parser-runtime';
|
||||||
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
||||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistLogout: false,
|
anilistLogout: false,
|
||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
|
dictionary: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
getYomitanDictionaryInfo,
|
||||||
|
importYomitanDictionaryFromZip,
|
||||||
|
deleteYomitanDictionaryByTitle,
|
||||||
|
removeYomitanDictionarySettings,
|
||||||
requestYomitanParseResults,
|
requestYomitanParseResults,
|
||||||
requestYomitanTermFrequencies,
|
requestYomitanTermFrequencies,
|
||||||
syncYomitanDefaultAnkiServer,
|
syncYomitanDefaultAnkiServer,
|
||||||
|
upsertYomitanDictionarySettings,
|
||||||
} from './yomitan-parser-runtime';
|
} from './yomitan-parser-runtime';
|
||||||
|
|
||||||
function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
|
function createDeps(
|
||||||
|
executeJavaScript: (script: string) => Promise<unknown>,
|
||||||
|
options?: {
|
||||||
|
createYomitanExtensionWindow?: (pageName: string) => Promise<unknown>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
const parserWindow = {
|
const parserWindow = {
|
||||||
isDestroyed: () => false,
|
isDestroyed: () => false,
|
||||||
webContents: {
|
webContents: {
|
||||||
@@ -22,6 +35,7 @@ function createDeps(executeJavaScript: (script: string) => Promise<unknown>) {
|
|||||||
setYomitanParserReadyPromise: () => undefined,
|
setYomitanParserReadyPromise: () => undefined,
|
||||||
getYomitanParserInitPromise: () => null,
|
getYomitanParserInitPromise: () => null,
|
||||||
setYomitanParserInitPromise: () => undefined,
|
setYomitanParserInitPromise: () => undefined,
|
||||||
|
createYomitanExtensionWindow: options?.createYomitanExtensionWindow as never,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,3 +431,126 @@ test('requestYomitanParseResults disables Yomitan MeCab parser path', async () =
|
|||||||
assert.ok(parseScript, 'expected parseText request script');
|
assert.ok(parseScript, 'expected parseText request script');
|
||||||
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
|
assert.match(parseScript ?? '', /useMecabParser:\s*false/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getYomitanDictionaryInfo requests dictionary info via backend action', async () => {
|
||||||
|
let scriptValue = '';
|
||||||
|
const deps = createDeps(async (script) => {
|
||||||
|
scriptValue = script;
|
||||||
|
return [{ title: 'SubMiner Character Dictionary (AniList 130298)', revision: '1' }];
|
||||||
|
});
|
||||||
|
|
||||||
|
const dictionaries = await getYomitanDictionaryInfo(deps, { error: () => undefined });
|
||||||
|
assert.equal(dictionaries.length, 1);
|
||||||
|
assert.equal(dictionaries[0]?.title, 'SubMiner Character Dictionary (AniList 130298)');
|
||||||
|
assert.match(scriptValue, /getDictionaryInfo/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dictionary settings helpers upsert and remove dictionary entries', async () => {
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const optionsFull = {
|
||||||
|
profileCurrent: 0,
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
options: {
|
||||||
|
dictionaries: [
|
||||||
|
{
|
||||||
|
name: 'SubMiner Character Dictionary (AniList 1)',
|
||||||
|
alias: 'SubMiner Character Dictionary (AniList 1)',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createDeps(async (script) => {
|
||||||
|
scripts.push(script);
|
||||||
|
if (script.includes('optionsGetFull')) {
|
||||||
|
return JSON.parse(JSON.stringify(optionsFull));
|
||||||
|
}
|
||||||
|
if (script.includes('setAllSettings')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = 'SubMiner Character Dictionary (AniList 1)';
|
||||||
|
const upserted = await upsertYomitanDictionarySettings(title, 'all', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
const removed = await removeYomitanDictionarySettings(title, 'all', 'delete', deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(upserted, true);
|
||||||
|
assert.equal(removed, true);
|
||||||
|
const setCalls = scripts.filter((script) => script.includes('setAllSettings')).length;
|
||||||
|
assert.equal(setCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importYomitanDictionaryFromZip uses settings automation bridge instead of custom backend action', async () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-import-'));
|
||||||
|
const zipPath = path.join(tempDir, 'dict.zip');
|
||||||
|
fs.writeFileSync(zipPath, Buffer.from('zip-bytes'));
|
||||||
|
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const settingsWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => undefined,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
scripts.push(script);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createDeps(async () => true, {
|
||||||
|
createYomitanExtensionWindow: async (pageName: string) => {
|
||||||
|
assert.equal(pageName, 'settings.html');
|
||||||
|
return settingsWindow;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imported = await importYomitanDictionaryFromZip(zipPath, deps, {
|
||||||
|
error: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(imported, true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('importDictionaryArchiveBase64')), true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('subminerImportDictionary')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of custom backend action', async () => {
|
||||||
|
const scripts: string[] = [];
|
||||||
|
const settingsWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
destroy: () => undefined,
|
||||||
|
webContents: {
|
||||||
|
executeJavaScript: async (script: string) => {
|
||||||
|
scripts.push(script);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = createDeps(async () => true, {
|
||||||
|
createYomitanExtensionWindow: async (pageName: string) => {
|
||||||
|
assert.equal(pageName, 'settings.html');
|
||||||
|
return settingsWindow;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = await deleteYomitanDictionaryByTitle(
|
||||||
|
'SubMiner Character Dictionary (AniList 130298)',
|
||||||
|
deps,
|
||||||
|
{ error: () => undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(deleted, true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('__subminerYomitanSettingsAutomation')), true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('deleteDictionary')), true);
|
||||||
|
assert.equal(scripts.some((script) => script.includes('subminerDeleteDictionary')), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { BrowserWindow, Extension } from 'electron';
|
import type { BrowserWindow, Extension } from 'electron';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
interface LoggerLike {
|
interface LoggerLike {
|
||||||
error: (message: string, ...args: unknown[]) => void;
|
error: (message: string, ...args: unknown[]) => void;
|
||||||
@@ -13,6 +15,12 @@ interface YomitanParserRuntimeDeps {
|
|||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
createYomitanExtensionWindow?: (pageName: string) => Promise<BrowserWindow | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YomitanDictionaryInfo {
|
||||||
|
title: string;
|
||||||
|
revision?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YomitanTermFrequency {
|
export interface YomitanTermFrequency {
|
||||||
@@ -489,6 +497,93 @@ async function ensureYomitanParserWindow(
|
|||||||
return initPromise;
|
return initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createYomitanExtensionWindow(
|
||||||
|
pageName: string,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<BrowserWindow | null> {
|
||||||
|
if (typeof deps.createYomitanExtensionWindow === 'function') {
|
||||||
|
return await deps.createYomitanExtensionWindow(pageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const electron = await import('electron');
|
||||||
|
const yomitanExt = deps.getYomitanExt();
|
||||||
|
if (!yomitanExt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { BrowserWindow, session } = electron;
|
||||||
|
const window = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
session: session.defaultSession,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
window.webContents.once('did-finish-load', () => resolve());
|
||||||
|
window.webContents.once('did-fail-load', (_event, _errorCode, errorDescription) => {
|
||||||
|
reject(new Error(errorDescription));
|
||||||
|
});
|
||||||
|
void window
|
||||||
|
.loadURL(`chrome-extension://${yomitanExt.id}/${pageName}`)
|
||||||
|
.catch((error: Error) => reject(error));
|
||||||
|
});
|
||||||
|
return window;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to create hidden Yomitan ${pageName} window: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.destroy();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeYomitanSettingsAutomation<T>(
|
||||||
|
script: string,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const settingsWindow = await createYomitanExtensionWindow('settings.html', deps, logger);
|
||||||
|
if (!settingsWindow || settingsWindow.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await settingsWindow.webContents.executeJavaScript(
|
||||||
|
`
|
||||||
|
(async () => {
|
||||||
|
const deadline = Date.now() + 10000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (globalThis.__subminerYomitanSettingsAutomation?.ready === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error("Yomitan settings automation bridge did not become ready");
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (await settingsWindow.webContents.executeJavaScript(script, true)) as T;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to drive Yomitan settings automation:', (err as Error).message);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (!settingsWindow.isDestroyed()) {
|
||||||
|
settingsWindow.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function requestYomitanParseResults(
|
export async function requestYomitanParseResults(
|
||||||
text: string,
|
text: string,
|
||||||
deps: YomitanParserRuntimeDeps,
|
deps: YomitanParserRuntimeDeps,
|
||||||
@@ -963,3 +1058,320 @@ export async function syncYomitanDefaultAnkiServer(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildYomitanInvokeScript(actionLiteral: string, paramsLiteral: string): string {
|
||||||
|
return `
|
||||||
|
(async () => {
|
||||||
|
const invoke = (action, params) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response || typeof response !== "object") {
|
||||||
|
reject(new Error("Invalid response from Yomitan backend"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await invoke(${actionLiteral}, ${paramsLiteral});
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeYomitanBackendAction<T>(
|
||||||
|
action: string,
|
||||||
|
params: unknown,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||||
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
|
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = buildYomitanInvokeScript(
|
||||||
|
JSON.stringify(action),
|
||||||
|
params === undefined ? 'undefined' : JSON.stringify(params),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await parserWindow.webContents.executeJavaScript(script, true)) as T;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Yomitan backend action failed (${action}):`, (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultDictionarySettings(name: string, enabled: boolean): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
alias: name,
|
||||||
|
enabled,
|
||||||
|
allowSecondarySearches: false,
|
||||||
|
definitionsCollapsible: 'not-collapsible',
|
||||||
|
partsOfSpeechFilter: true,
|
||||||
|
useDeinflections: true,
|
||||||
|
styles: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTargetProfileIndices(
|
||||||
|
optionsFull: Record<string, unknown>,
|
||||||
|
profileScope: 'all' | 'active',
|
||||||
|
): number[] {
|
||||||
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
|
if (profileScope === 'active') {
|
||||||
|
const profileCurrent =
|
||||||
|
typeof optionsFull.profileCurrent === 'number' && Number.isFinite(optionsFull.profileCurrent)
|
||||||
|
? Math.max(0, Math.floor(optionsFull.profileCurrent))
|
||||||
|
: 0;
|
||||||
|
return profileCurrent < profiles.length ? [profileCurrent] : [];
|
||||||
|
}
|
||||||
|
return profiles.map((_profile, index) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYomitanDictionaryInfo(
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<YomitanDictionaryInfo[]> {
|
||||||
|
const result = await invokeYomitanBackendAction<unknown>('getDictionaryInfo', undefined, deps, logger);
|
||||||
|
if (!Array.isArray(result)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.filter((entry): entry is Record<string, unknown> => isObject(entry))
|
||||||
|
.map((entry) => {
|
||||||
|
const title = typeof entry.title === 'string' ? entry.title.trim() : '';
|
||||||
|
const revision = entry.revision;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
revision:
|
||||||
|
typeof revision === 'string' || typeof revision === 'number' ? revision : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.title.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getYomitanSettingsFull(
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
const result = await invokeYomitanBackendAction<unknown>('optionsGetFull', undefined, deps, logger);
|
||||||
|
return isObject(result) ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setYomitanSettingsFull(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
source = 'subminer',
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await invokeYomitanBackendAction<unknown>(
|
||||||
|
'setAllSettings',
|
||||||
|
{ value, source },
|
||||||
|
deps,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
return result !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importYomitanDictionaryFromZip(
|
||||||
|
zipPath: string,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedZipPath = zipPath.trim();
|
||||||
|
if (!normalizedZipPath || !fs.existsSync(normalizedZipPath)) {
|
||||||
|
logger.error(`Dictionary ZIP not found: ${zipPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveBase64 = fs.readFileSync(normalizedZipPath).toString('base64');
|
||||||
|
const script = `
|
||||||
|
(async () => {
|
||||||
|
await globalThis.__subminerYomitanSettingsAutomation.importDictionaryArchiveBase64(
|
||||||
|
${JSON.stringify(archiveBase64)},
|
||||||
|
${JSON.stringify(path.basename(normalizedZipPath))}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
const result = await invokeYomitanSettingsAutomation<boolean>(script, deps, logger);
|
||||||
|
return result === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteYomitanDictionaryByTitle(
|
||||||
|
dictionaryTitle: string,
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedTitle = dictionaryTitle.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await invokeYomitanSettingsAutomation<boolean>(
|
||||||
|
`
|
||||||
|
(async () => {
|
||||||
|
await globalThis.__subminerYomitanSettingsAutomation.deleteDictionary(
|
||||||
|
${JSON.stringify(normalizedTitle)}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
deps,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
return result === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertYomitanDictionarySettings(
|
||||||
|
dictionaryTitle: string,
|
||||||
|
profileScope: 'all' | 'active',
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedTitle = dictionaryTitle.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsFull = await getYomitanSettingsFull(deps, logger);
|
||||||
|
if (!optionsFull) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
|
const indices = getTargetProfileIndices(optionsFull, profileScope);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const index of indices) {
|
||||||
|
const profile = profiles[index];
|
||||||
|
if (!isObject(profile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(profile.options)) {
|
||||||
|
profile.options = {};
|
||||||
|
}
|
||||||
|
const profileOptions = profile.options as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(profileOptions.dictionaries)) {
|
||||||
|
profileOptions.dictionaries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictionaries = profileOptions.dictionaries as unknown[];
|
||||||
|
const existingIndex = dictionaries.findIndex(
|
||||||
|
(entry) =>
|
||||||
|
isObject(entry) &&
|
||||||
|
typeof (entry as { name?: unknown }).name === 'string' &&
|
||||||
|
((entry as { name: string }).name.trim() === normalizedTitle),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = dictionaries[existingIndex] as Record<string, unknown>;
|
||||||
|
if (existing.enabled !== true) {
|
||||||
|
existing.enabled = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (typeof existing.alias !== 'string' || existing.alias.trim().length === 0) {
|
||||||
|
existing.alias = normalizedTitle;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (existingIndex > 0) {
|
||||||
|
dictionaries.splice(existingIndex, 1);
|
||||||
|
dictionaries.unshift(existing);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionaries.unshift(createDefaultDictionarySettings(normalizedTitle, true));
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await setYomitanSettingsFull(optionsFull, deps, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeYomitanDictionarySettings(
|
||||||
|
dictionaryTitle: string,
|
||||||
|
profileScope: 'all' | 'active',
|
||||||
|
mode: 'delete' | 'disable',
|
||||||
|
deps: YomitanParserRuntimeDeps,
|
||||||
|
logger: LoggerLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalizedTitle = dictionaryTitle.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsFull = await getYomitanSettingsFull(deps, logger);
|
||||||
|
if (!optionsFull) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||||
|
const indices = getTargetProfileIndices(optionsFull, profileScope);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const index of indices) {
|
||||||
|
const profile = profiles[index];
|
||||||
|
if (!isObject(profile) || !isObject(profile.options)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profileOptions = profile.options as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(profileOptions.dictionaries)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictionaries = profileOptions.dictionaries as unknown[];
|
||||||
|
if (mode === 'delete') {
|
||||||
|
const before = dictionaries.length;
|
||||||
|
profileOptions.dictionaries = dictionaries.filter(
|
||||||
|
(entry) =>
|
||||||
|
!(
|
||||||
|
isObject(entry) &&
|
||||||
|
typeof (entry as { name?: unknown }).name === 'string' &&
|
||||||
|
(entry as { name: string }).name.trim() === normalizedTitle
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if ((profileOptions.dictionaries as unknown[]).length !== before) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of dictionaries) {
|
||||||
|
if (
|
||||||
|
!isObject(entry) ||
|
||||||
|
typeof (entry as { name?: unknown }).name !== 'string' ||
|
||||||
|
(entry as { name: string }).name.trim() !== normalizedTitle
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dictionaryEntry = entry as Record<string, unknown>;
|
||||||
|
if (dictionaryEntry.enabled !== false) {
|
||||||
|
dictionaryEntry.enabled = false;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await setYomitanSettingsFull(optionsFull, deps, logger);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
@@ -17,6 +18,41 @@ function readManifestVersion(manifestPath: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashDirectoryContents(dirPath: string): string | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
const queue = [''];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const relativeDir = queue.shift()!;
|
||||||
|
const absoluteDir = path.join(dirPath, relativeDir);
|
||||||
|
const entries = fs.readdirSync(absoluteDir, { withFileTypes: true });
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relativePath = path.join(relativeDir, entry.name);
|
||||||
|
const normalizedRelativePath = relativePath.split(path.sep).join('/');
|
||||||
|
hash.update(normalizedRelativePath);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
queue.push(relativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hash.update(fs.readFileSync(path.join(dirPath, relativePath)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.digest('hex');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
||||||
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
||||||
try {
|
try {
|
||||||
@@ -49,5 +85,32 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
const sourceHash = hashDirectoryContents(sourceDir);
|
||||||
|
const targetHash = hashDirectoryContents(targetDir);
|
||||||
|
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): {
|
||||||
|
targetDir: string;
|
||||||
|
copied: boolean;
|
||||||
|
} {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return { targetDir: sourceDir, copied: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||||
|
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||||
|
|
||||||
|
let shouldCopy = !fs.existsSync(targetDir);
|
||||||
|
if (!shouldCopy) {
|
||||||
|
shouldCopy = hashDirectoryContents(sourceDir) !== hashDirectoryContents(targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldCopy) {
|
||||||
|
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||||
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { targetDir, copied: shouldCopy };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||||
|
|
||||||
|
function makeTempDir(prefix: string): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
function writeFile(filePath: string, content: string): void {
|
function writeFile(filePath: string, content: string): void {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
@@ -12,41 +16,66 @@ function writeFile(filePath: string, content: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||||
const sourceDir = path.join(tempRoot, 'source');
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
const targetDir = path.join(tempRoot, 'target');
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'source-popup-main');
|
||||||
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'target-popup-main');
|
||||||
|
|
||||||
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shouldCopyYomitanExtension skips copy when versions and watched scripts match', () => {
|
test('shouldCopyYomitanExtension skips copy when extension contents match', () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yomitan-copy-test-'));
|
const tempRoot = makeTempDir('subminer-yomitan-copy-');
|
||||||
const sourceDir = path.join(tempRoot, 'source');
|
const sourceDir = path.join(tempRoot, 'source');
|
||||||
const targetDir = path.join(tempRoot, 'target');
|
const targetDir = path.join(tempRoot, 'target');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
writeFile(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
writeFile(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
writeFile(path.join(sourceDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
writeFile(path.join(targetDir, 'js', 'app', 'popup.js'), 'same-popup-script');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
writeFile(path.join(sourceDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
writeFile(path.join(targetDir, 'js', 'display', 'popup-main.js'), 'same-popup-main');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
|
writeFile(path.join(sourceDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
|
writeFile(path.join(targetDir, 'js', 'display', 'display.js'), 'same-display');
|
||||||
|
|
||||||
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
writeFile(path.join(sourceDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
writeFile(path.join(targetDir, 'js', 'display', 'display-audio.js'), 'same-display-audio');
|
||||||
|
|
||||||
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
|
assert.equal(shouldCopyYomitanExtension(sourceDir, targetDir), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureExtensionCopy refreshes copied extension when display files change', () => {
|
||||||
|
const sourceRoot = makeTempDir('subminer-yomitan-src-');
|
||||||
|
const userDataRoot = makeTempDir('subminer-yomitan-user-');
|
||||||
|
|
||||||
|
const sourceDir = path.join(sourceRoot, 'yomitan');
|
||||||
|
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
|
||||||
|
'new display code',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
|
||||||
|
'old display code',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = ensureExtensionCopy(sourceDir, userDataRoot);
|
||||||
|
|
||||||
|
assert.equal(result.targetDir, targetDir);
|
||||||
|
assert.equal(result.copied, true);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetDir, 'js', 'display', 'structured-content-generator.js'), 'utf8'),
|
||||||
|
'new display code',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BrowserWindow, Extension, session } from 'electron';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
import { shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
||||||
|
|
||||||
const logger = createLogger('main:yomitan-extension-loader');
|
const logger = createLogger('main:yomitan-extension-loader');
|
||||||
|
|
||||||
@@ -15,26 +15,6 @@ export interface YomitanExtensionLoaderDeps {
|
|||||||
setYomitanExtension: (extension: Extension | null) => void;
|
setYomitanExtension: (extension: Extension | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return sourceDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
|
||||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
|
||||||
|
|
||||||
const shouldCopy = shouldCopyYomitanExtension(sourceDir, targetDir);
|
|
||||||
|
|
||||||
if (shouldCopy) {
|
|
||||||
fs.mkdirSync(extensionsRoot, { recursive: true });
|
|
||||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
||||||
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
|
||||||
logger.info(`Copied yomitan extension to ${targetDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadYomitanExtension(
|
export async function loadYomitanExtension(
|
||||||
deps: YomitanExtensionLoaderDeps,
|
deps: YomitanExtensionLoaderDeps,
|
||||||
): Promise<Extension | null> {
|
): Promise<Extension | null> {
|
||||||
@@ -60,7 +40,11 @@ export async function loadYomitanExtension(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
extPath = ensureExtensionCopy(extPath, deps.userDataPath);
|
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||||
|
if (extensionCopy.copied) {
|
||||||
|
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||||
|
}
|
||||||
|
extPath = extensionCopy.targetDir;
|
||||||
|
|
||||||
const parserWindow = deps.getYomitanParserWindow();
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
if (parserWindow && !parserWindow.isDestroyed()) {
|
if (parserWindow && !parserWindow.isDestroyed()) {
|
||||||
|
|||||||
239
src/core/services/yomitan-structured-content-generator.test.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
class FakeStyle {
|
||||||
|
private values = new Map<string, string>();
|
||||||
|
|
||||||
|
set width(value: string) {
|
||||||
|
this.values.set('width', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get width(): string {
|
||||||
|
return this.values.get('width') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
set height(value: string) {
|
||||||
|
this.values.set('height', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get height(): string {
|
||||||
|
return this.values.get('height') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
set border(value: string) {
|
||||||
|
this.values.set('border', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set borderRadius(value: string) {
|
||||||
|
this.values.set('borderRadius', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
set paddingTop(value: string) {
|
||||||
|
this.values.set('paddingTop', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperty(name: string, value: string): void {
|
||||||
|
this.values.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProperty(name: string): void {
|
||||||
|
this.values.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeNode {
|
||||||
|
public childNodes: Array<FakeNode | FakeTextNode> = [];
|
||||||
|
public className = '';
|
||||||
|
public dataset: Record<string, string> = {};
|
||||||
|
public style = new FakeStyle();
|
||||||
|
public textContent: string | null = null;
|
||||||
|
public title = '';
|
||||||
|
public href = '';
|
||||||
|
public rel = '';
|
||||||
|
public target = '';
|
||||||
|
public width = 0;
|
||||||
|
public height = 0;
|
||||||
|
public parentNode: FakeNode | null = null;
|
||||||
|
|
||||||
|
constructor(public readonly tagName: string) {}
|
||||||
|
|
||||||
|
appendChild(node: FakeNode | FakeTextNode): FakeNode | FakeTextNode {
|
||||||
|
if (node instanceof FakeNode) {
|
||||||
|
node.parentNode = this;
|
||||||
|
}
|
||||||
|
this.childNodes.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(): void {}
|
||||||
|
|
||||||
|
closest(selector: string): FakeNode | null {
|
||||||
|
if (!selector.startsWith('.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const className = selector.slice(1);
|
||||||
|
let current: FakeNode | null = this;
|
||||||
|
while (current) {
|
||||||
|
if (current.className === className) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAttribute(name: string): void {
|
||||||
|
if (name === 'src') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (name === 'href') {
|
||||||
|
this.href = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeImageElement extends FakeNode {
|
||||||
|
public onload: (() => void) | null = null;
|
||||||
|
public onerror: ((error: unknown) => void) | null = null;
|
||||||
|
private _src = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('img');
|
||||||
|
}
|
||||||
|
|
||||||
|
set src(value: string) {
|
||||||
|
this._src = value;
|
||||||
|
this.onload?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
get src(): string {
|
||||||
|
return this._src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCanvasElement extends FakeNode {
|
||||||
|
constructor() {
|
||||||
|
super('canvas');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTextNode {
|
||||||
|
constructor(public readonly data: string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
createElement(tagName: string): FakeNode {
|
||||||
|
if (tagName === 'img') {
|
||||||
|
return new FakeImageElement();
|
||||||
|
}
|
||||||
|
if (tagName === 'canvas') {
|
||||||
|
return new FakeCanvasElement();
|
||||||
|
}
|
||||||
|
return new FakeNode(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTextNode(data: string): FakeTextNode {
|
||||||
|
return new FakeTextNode(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstByClass(node: FakeNode, className: string): FakeNode | null {
|
||||||
|
if (node.className === className) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (child instanceof FakeNode) {
|
||||||
|
const result = findFirstByClass(child, className);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('StructuredContentGenerator uses direct img loading for popup glossary images', async () => {
|
||||||
|
const { DisplayContentManager } = await import(
|
||||||
|
pathToFileURL(
|
||||||
|
path.join(process.cwd(), 'vendor/yomitan/js/display/display-content-manager.js'),
|
||||||
|
).href
|
||||||
|
);
|
||||||
|
const { StructuredContentGenerator } = await import(
|
||||||
|
pathToFileURL(
|
||||||
|
path.join(process.cwd(), 'vendor/yomitan/js/display/structured-content-generator.js'),
|
||||||
|
).href
|
||||||
|
);
|
||||||
|
|
||||||
|
const createObjectURLCalls: string[] = [];
|
||||||
|
const revokeObjectURLCalls: string[] = [];
|
||||||
|
const originalHtmlImageElement = globalThis.HTMLImageElement;
|
||||||
|
const originalHtmlCanvasElement = globalThis.HTMLCanvasElement;
|
||||||
|
const originalCreateObjectURL = URL.createObjectURL;
|
||||||
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||||
|
globalThis.HTMLImageElement = FakeImageElement as unknown as typeof HTMLImageElement;
|
||||||
|
globalThis.HTMLCanvasElement = FakeCanvasElement as unknown as typeof HTMLCanvasElement;
|
||||||
|
URL.createObjectURL = (_blob: Blob) => {
|
||||||
|
const value = 'blob:test-image';
|
||||||
|
createObjectURLCalls.push(value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
URL.revokeObjectURL = (value: string) => {
|
||||||
|
revokeObjectURLCalls.push(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DisplayContentManager({
|
||||||
|
application: {
|
||||||
|
api: {
|
||||||
|
getMedia: async () => [
|
||||||
|
{
|
||||||
|
content: Buffer.from('png-bytes').toString('base64'),
|
||||||
|
mediaType: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const generator = new StructuredContentGenerator(
|
||||||
|
manager,
|
||||||
|
new FakeDocument(),
|
||||||
|
{
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
navigator: { userAgent: 'Mozilla/5.0' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = generator.createDefinitionImage(
|
||||||
|
{
|
||||||
|
tag: 'img',
|
||||||
|
path: 'img/test.png',
|
||||||
|
width: 8,
|
||||||
|
height: 11,
|
||||||
|
title: 'Alpha',
|
||||||
|
background: true,
|
||||||
|
},
|
||||||
|
'SubMiner Character Dictionary',
|
||||||
|
) as FakeNode;
|
||||||
|
|
||||||
|
await manager.executeMediaRequests();
|
||||||
|
|
||||||
|
const imageNode = findFirstByClass(node, 'gloss-image');
|
||||||
|
assert.ok(imageNode);
|
||||||
|
assert.equal(imageNode.tagName, 'img');
|
||||||
|
assert.equal((imageNode as FakeImageElement).src, 'blob:test-image');
|
||||||
|
assert.equal(node.dataset.imageLoadState, 'loaded');
|
||||||
|
assert.equal(node.dataset.hasImage, 'true');
|
||||||
|
assert.deepEqual(createObjectURLCalls, ['blob:test-image']);
|
||||||
|
|
||||||
|
manager.unloadAll();
|
||||||
|
assert.deepEqual(revokeObjectURLCalls, ['blob:test-image']);
|
||||||
|
} finally {
|
||||||
|
globalThis.HTMLImageElement = originalHtmlImageElement;
|
||||||
|
globalThis.HTMLCanvasElement = originalHtmlCanvasElement;
|
||||||
|
URL.createObjectURL = originalCreateObjectURL;
|
||||||
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||||
|
}
|
||||||
|
});
|
||||||
120
src/main.ts
@@ -339,11 +339,14 @@ import {
|
|||||||
createSubtitleProcessingController,
|
createSubtitleProcessingController,
|
||||||
createTokenizerDepsRuntime,
|
createTokenizerDepsRuntime,
|
||||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||||
|
deleteYomitanDictionaryByTitle,
|
||||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||||
|
getYomitanDictionaryInfo,
|
||||||
handleMineSentenceDigit as handleMineSentenceDigitCore,
|
handleMineSentenceDigit as handleMineSentenceDigitCore,
|
||||||
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
handleMultiCopyDigit as handleMultiCopyDigitCore,
|
||||||
hasMpvWebsocketPlugin,
|
hasMpvWebsocketPlugin,
|
||||||
|
importYomitanDictionaryFromZip,
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||||
jellyfinTicksToSecondsRuntime,
|
jellyfinTicksToSecondsRuntime,
|
||||||
listJellyfinItemsRuntime,
|
listJellyfinItemsRuntime,
|
||||||
@@ -358,6 +361,7 @@ import {
|
|||||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||||
replayCurrentSubtitleRuntime,
|
replayCurrentSubtitleRuntime,
|
||||||
resolveJellyfinPlaybackPlanRuntime,
|
resolveJellyfinPlaybackPlanRuntime,
|
||||||
|
removeYomitanDictionarySettings,
|
||||||
runStartupBootstrapRuntime,
|
runStartupBootstrapRuntime,
|
||||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||||
clearYomitanParserCachesForWindow,
|
clearYomitanParserCachesForWindow,
|
||||||
@@ -370,6 +374,7 @@ import {
|
|||||||
showMpvOsdRuntime,
|
showMpvOsdRuntime,
|
||||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||||
|
upsertYomitanDictionarySettings,
|
||||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||||
} from './core/services';
|
} from './core/services';
|
||||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||||
@@ -416,6 +421,8 @@ import {
|
|||||||
} from './main/jlpt-runtime';
|
} from './main/jlpt-runtime';
|
||||||
import { createMediaRuntimeService } from './main/media-runtime';
|
import { createMediaRuntimeService } from './main/media-runtime';
|
||||||
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
||||||
|
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||||
|
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||||
import {
|
import {
|
||||||
type AnilistMediaGuessRuntimeState,
|
type AnilistMediaGuessRuntimeState,
|
||||||
type AppState,
|
type AppState,
|
||||||
@@ -1216,6 +1223,75 @@ const mediaRuntime = createMediaRuntimeService(
|
|||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath: USER_DATA_PATH,
|
||||||
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
|
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||||
|
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||||
|
now: () => Date.now(),
|
||||||
|
logInfo: (message) => logger.info(message),
|
||||||
|
logWarn: (message) => logger.warn(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath: USER_DATA_PATH,
|
||||||
|
getConfig: () => getResolvedConfig().anilist.characterDictionary,
|
||||||
|
generateCharacterDictionary: (options) =>
|
||||||
|
characterDictionaryRuntime.generateForCurrentMedia(undefined, options),
|
||||||
|
getYomitanDictionaryInfo: async () => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||||
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
importYomitanDictionary: async (zipPath) => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
return await importYomitanDictionaryFromZip(zipPath, getYomitanParserRuntimeDeps(), {
|
||||||
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
return await deleteYomitanDictionaryByTitle(dictionaryTitle, getYomitanParserRuntimeDeps(), {
|
||||||
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
return await upsertYomitanDictionarySettings(
|
||||||
|
dictionaryTitle,
|
||||||
|
profileScope,
|
||||||
|
getYomitanParserRuntimeDeps(),
|
||||||
|
{
|
||||||
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeYomitanDictionarySettings: async (dictionaryTitle, profileScope, mode) => {
|
||||||
|
await ensureYomitanExtensionLoaded();
|
||||||
|
return await removeYomitanDictionarySettings(
|
||||||
|
dictionaryTitle,
|
||||||
|
profileScope,
|
||||||
|
mode,
|
||||||
|
getYomitanParserRuntimeDeps(),
|
||||||
|
{
|
||||||
|
error: (message, ...args) => logger.error(message, ...args),
|
||||||
|
info: (message, ...args) => logger.info(message, ...args),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
now: () => Date.now(),
|
||||||
|
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||||
|
clearSchedule: (timer) => clearTimeout(timer),
|
||||||
|
logInfo: (message) => logger.info(message),
|
||||||
|
logWarn: (message) => logger.warn(message),
|
||||||
|
});
|
||||||
|
|
||||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||||
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
createBuildOverlayVisibilityRuntimeMainDepsHandler({
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
@@ -2204,7 +2280,10 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
|
|||||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||||
handleInitialArgs: () => handleInitialArgs(),
|
handleInitialArgs: () => handleInitialArgs(),
|
||||||
shouldSkipHeavyStartup: () =>
|
shouldSkipHeavyStartup: () =>
|
||||||
Boolean(appState.initialArgs && shouldRunSettingsOnlyStartup(appState.initialArgs)),
|
Boolean(
|
||||||
|
appState.initialArgs &&
|
||||||
|
(shouldRunSettingsOnlyStartup(appState.initialArgs) || appState.initialArgs.dictionary),
|
||||||
|
),
|
||||||
createImmersionTracker: () => {
|
createImmersionTracker: () => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
},
|
},
|
||||||
@@ -2373,6 +2452,9 @@ const {
|
|||||||
syncImmersionMediaState: () => {
|
syncImmersionMediaState: () => {
|
||||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||||
},
|
},
|
||||||
|
scheduleCharacterDictionarySync: () => {
|
||||||
|
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||||
|
},
|
||||||
updateCurrentMediaTitle: (title) => {
|
updateCurrentMediaTitle: (title) => {
|
||||||
mediaRuntime.updateCurrentMediaTitle(title);
|
mediaRuntime.updateCurrentMediaTitle(title);
|
||||||
},
|
},
|
||||||
@@ -2638,6 +2720,24 @@ function getPreferredYomitanAnkiServerUrl(): string {
|
|||||||
return config.url;
|
return config.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getYomitanParserRuntimeDeps() {
|
||||||
|
return {
|
||||||
|
getYomitanExt: () => appState.yomitanExt,
|
||||||
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
|
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||||
|
appState.yomitanParserWindow = window;
|
||||||
|
},
|
||||||
|
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
||||||
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
|
||||||
|
appState.yomitanParserReadyPromise = promise;
|
||||||
|
},
|
||||||
|
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
||||||
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
|
||||||
|
appState.yomitanParserInitPromise = promise;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
||||||
@@ -2646,21 +2746,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
|||||||
|
|
||||||
const synced = await syncYomitanDefaultAnkiServerCore(
|
const synced = await syncYomitanDefaultAnkiServerCore(
|
||||||
targetUrl,
|
targetUrl,
|
||||||
{
|
getYomitanParserRuntimeDeps(),
|
||||||
getYomitanExt: () => appState.yomitanExt,
|
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
appState.yomitanParserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
appState.yomitanParserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
appState.yomitanParserInitPromise = promise;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
error: (message, ...args) => {
|
error: (message, ...args) => {
|
||||||
logger.error(message, ...args);
|
logger.error(message, ...args);
|
||||||
@@ -3130,6 +3216,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
|||||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||||
|
generateCharacterDictionary: (targetPath?: string) =>
|
||||||
|
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
||||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||||
|
|||||||
346
src/main/character-dictionary-runtime.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createCharacterDictionaryRuntimeService } from './character-dictionary-runtime';
|
||||||
|
|
||||||
|
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||||
|
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||||
|
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||||
|
const PNG_1X1 = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nmX8AAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-character-dictionary-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredZipEntry(zipPath: string, entryName: string): Buffer {
|
||||||
|
const archive = fs.readFileSync(zipPath);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 4 <= archive.length) {
|
||||||
|
const signature = archive.readUInt32LE(offset);
|
||||||
|
if (
|
||||||
|
signature === CENTRAL_DIRECTORY_SIGNATURE ||
|
||||||
|
signature === END_OF_CENTRAL_DIRECTORY_SIGNATURE
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (signature !== LOCAL_FILE_HEADER_SIGNATURE) {
|
||||||
|
throw new Error(`Unexpected ZIP signature 0x${signature.toString(16)} at offset ${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressionMethod = archive.readUInt16LE(offset + 8);
|
||||||
|
assert.equal(compressionMethod, 0, 'expected stored ZIP entry');
|
||||||
|
const compressedSize = archive.readUInt32LE(offset + 18);
|
||||||
|
const fileNameLength = archive.readUInt16LE(offset + 26);
|
||||||
|
const extraFieldLength = archive.readUInt16LE(offset + 28);
|
||||||
|
const fileNameStart = offset + 30;
|
||||||
|
const fileNameEnd = fileNameStart + fileNameLength;
|
||||||
|
const fileName = archive.subarray(fileNameStart, fileNameEnd).toString('utf8');
|
||||||
|
const dataStart = fileNameEnd + extraFieldLength;
|
||||||
|
const dataEnd = dataStart + compressedSize;
|
||||||
|
|
||||||
|
if (fileName === entryName) {
|
||||||
|
return archive.subarray(dataStart, dataEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = dataEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`ZIP entry not found: ${entryName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateForCurrentMedia emits structured-content glossary so image stays with text', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 130298,
|
||||||
|
episodes: 20,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'SUPPORTING',
|
||||||
|
node: {
|
||||||
|
id: 123,
|
||||||
|
description:
|
||||||
|
'__Race:__ Human Alexia Midgar is the second princess of the Kingdom of Midgar.',
|
||||||
|
image: {
|
||||||
|
large: 'https://example.com/alexia.png',
|
||||||
|
medium: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
full: 'Alexia Midgar',
|
||||||
|
native: 'アレクシア・ミドガル',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === 'https://example.com/alexia.png') {
|
||||||
|
return new Response(PNG_1X1, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/png' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array<
|
||||||
|
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string]
|
||||||
|
>;
|
||||||
|
const alexia = termBank.find(([term]) => term === 'アレクシア');
|
||||||
|
|
||||||
|
assert.ok(alexia, 'expected compact native-name variant for character');
|
||||||
|
const glossary = alexia[5];
|
||||||
|
assert.equal(glossary.length, 1);
|
||||||
|
|
||||||
|
const entry = glossary[0] as {
|
||||||
|
type: string;
|
||||||
|
content: unknown[];
|
||||||
|
};
|
||||||
|
assert.equal(entry.type, 'structured-content');
|
||||||
|
assert.equal(Array.isArray(entry.content), true);
|
||||||
|
|
||||||
|
const image = entry.content[0] as Record<string, unknown>;
|
||||||
|
assert.equal(image.tag, 'img');
|
||||||
|
assert.equal(image.path, 'img/c123.png');
|
||||||
|
assert.equal(image.sizeUnits, 'em');
|
||||||
|
|
||||||
|
const descriptionLine = entry.content[5];
|
||||||
|
assert.equal(
|
||||||
|
descriptionLine,
|
||||||
|
'Race: Human Alexia Midgar is the second princess of the Kingdom of Midgar.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const topLevelImageGlossaryEntry = glossary.find(
|
||||||
|
(item) => typeof item === 'object' && item !== null && (item as { type?: string }).type === 'image',
|
||||||
|
);
|
||||||
|
assert.equal(topLevelImageGlossaryEntry, undefined);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia regenerates dictionary when cached format version is stale', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
|
fs.mkdirSync(dictionariesDir, { recursive: true });
|
||||||
|
|
||||||
|
const staleZipPath = path.join(dictionariesDir, 'anilist-130298.zip');
|
||||||
|
fs.writeFileSync(staleZipPath, Buffer.from('not-a-real-zip'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dictionariesDir, 'cache.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
anilistById: {
|
||||||
|
'130298': {
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 1,
|
||||||
|
zipPath: staleZipPath,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
formatVersion: 6,
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
|
||||||
|
revision: 'stale-revision',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let characterQueryCount = 0;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 130298,
|
||||||
|
episodes: 20,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
characterQueryCount += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'MAIN',
|
||||||
|
node: {
|
||||||
|
id: 321,
|
||||||
|
description: 'Alpha is the second-in-command of Shadow Garden.',
|
||||||
|
image: {
|
||||||
|
large: 'https://example.com/alpha.png',
|
||||||
|
medium: null,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
full: 'Alpha',
|
||||||
|
native: 'アルファ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === 'https://example.com/alpha.png') {
|
||||||
|
return new Response(PNG_1X1, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/png' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia(undefined, {
|
||||||
|
refreshTtlMs: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
assert.equal(result.fromCache, false);
|
||||||
|
assert.equal(characterQueryCount, 1);
|
||||||
|
|
||||||
|
const termBank = JSON.parse(readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8')) as Array<
|
||||||
|
[string, string, string, string, number, Array<string | Record<string, unknown>>, number, string]
|
||||||
|
>;
|
||||||
|
const alpha = termBank.find(([term]) => term === 'アルファ');
|
||||||
|
assert.ok(alpha);
|
||||||
|
assert.equal((alpha[5][0] as { type?: string }).type, 'structured-content');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
955
src/main/character-dictionary-runtime.ts
Normal file
@@ -0,0 +1,955 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
||||||
|
|
||||||
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
const HONORIFIC_SUFFIXES = [
|
||||||
|
'さん',
|
||||||
|
'様',
|
||||||
|
'先生',
|
||||||
|
'先輩',
|
||||||
|
'後輩',
|
||||||
|
'氏',
|
||||||
|
'君',
|
||||||
|
'くん',
|
||||||
|
'ちゃん',
|
||||||
|
'たん',
|
||||||
|
'坊',
|
||||||
|
'殿',
|
||||||
|
'博士',
|
||||||
|
'社長',
|
||||||
|
'部長',
|
||||||
|
] as const;
|
||||||
|
const VIDEO_EXTENSIONS = new Set([
|
||||||
|
'.mkv',
|
||||||
|
'.mp4',
|
||||||
|
'.avi',
|
||||||
|
'.webm',
|
||||||
|
'.mov',
|
||||||
|
'.flv',
|
||||||
|
'.wmv',
|
||||||
|
'.m4v',
|
||||||
|
'.ts',
|
||||||
|
'.m2ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
|
||||||
|
|
||||||
|
type CharacterDictionaryCacheEntry = {
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
zipPath: string;
|
||||||
|
updatedAt: number;
|
||||||
|
formatVersion?: number;
|
||||||
|
dictionaryTitle?: string;
|
||||||
|
revision?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CharacterDictionaryCacheFile = {
|
||||||
|
anilistById: Record<string, CharacterDictionaryCacheEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHARACTER_DICTIONARY_FORMAT_VERSION = 8;
|
||||||
|
|
||||||
|
type AniListSearchResponse = {
|
||||||
|
Page?: {
|
||||||
|
media?: Array<{
|
||||||
|
id: number;
|
||||||
|
episodes?: number | null;
|
||||||
|
title?: {
|
||||||
|
romaji?: string | null;
|
||||||
|
english?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type AniListCharacterPageResponse = {
|
||||||
|
Media?: {
|
||||||
|
title?: {
|
||||||
|
romaji?: string | null;
|
||||||
|
english?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
};
|
||||||
|
characters?: {
|
||||||
|
pageInfo?: {
|
||||||
|
hasNextPage?: boolean | null;
|
||||||
|
};
|
||||||
|
edges?: Array<{
|
||||||
|
role?: string | null;
|
||||||
|
node?: {
|
||||||
|
id: number;
|
||||||
|
description?: string | null;
|
||||||
|
image?: {
|
||||||
|
large?: string | null;
|
||||||
|
medium?: string | null;
|
||||||
|
} | null;
|
||||||
|
name?: {
|
||||||
|
full?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
} | null>;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CharacterRecord = {
|
||||||
|
id: number;
|
||||||
|
role: CharacterDictionaryRole;
|
||||||
|
fullName: string;
|
||||||
|
nativeName: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZipEntry = {
|
||||||
|
name: string;
|
||||||
|
data: Buffer;
|
||||||
|
crc32: number;
|
||||||
|
localHeaderOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CharacterDictionaryBuildResult = {
|
||||||
|
zipPath: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
dictionaryTitle?: string;
|
||||||
|
revision?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CharacterDictionaryGenerateOptions = {
|
||||||
|
refreshTtlMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CharacterDictionaryRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
getCurrentMediaPath: () => string | null;
|
||||||
|
getCurrentMediaTitle: () => string | null;
|
||||||
|
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||||
|
guessAnilistMediaInfo: (
|
||||||
|
mediaPath: string | null,
|
||||||
|
mediaTitle: string | null,
|
||||||
|
) => Promise<AnilistMediaGuess | null>;
|
||||||
|
now: () => number;
|
||||||
|
logInfo?: (message: string) => void;
|
||||||
|
logWarn?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedAniListMedia = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAniListSearchResult(
|
||||||
|
title: string,
|
||||||
|
episode: number | null,
|
||||||
|
media: Array<{
|
||||||
|
id: number;
|
||||||
|
episodes?: number | null;
|
||||||
|
title?: {
|
||||||
|
romaji?: string | null;
|
||||||
|
english?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
): ResolvedAniListMedia | null {
|
||||||
|
if (media.length === 0) return null;
|
||||||
|
|
||||||
|
const episodeFiltered =
|
||||||
|
typeof episode === 'number' && episode > 0
|
||||||
|
? media.filter((entry) => entry.episodes == null || entry.episodes >= episode)
|
||||||
|
: media;
|
||||||
|
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||||
|
const normalizedInput = normalizeTitle(title);
|
||||||
|
const exact = candidates.find((entry) => {
|
||||||
|
const candidateTitles = [entry.title?.romaji, entry.title?.english, entry.title?.native]
|
||||||
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
|
.map((value) => normalizeTitle(value));
|
||||||
|
return candidateTitles.includes(normalizedInput);
|
||||||
|
});
|
||||||
|
const selected = exact ?? candidates[0]!;
|
||||||
|
const selectedTitle =
|
||||||
|
selected.title?.english?.trim() ||
|
||||||
|
selected.title?.romaji?.trim() ||
|
||||||
|
selected.title?.native?.trim() ||
|
||||||
|
title;
|
||||||
|
return {
|
||||||
|
id: selected.id,
|
||||||
|
title: selectedTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKanaOnly(value: string): boolean {
|
||||||
|
return /^[\u3040-\u309f\u30a0-\u30ffー]+$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function katakanaToHiragana(value: string): string {
|
||||||
|
let output = '';
|
||||||
|
for (const char of value) {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
if (code >= 0x30a1 && code <= 0x30f6) {
|
||||||
|
output += String.fromCharCode(code - 0x60);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output += char;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReading(term: string): string {
|
||||||
|
const compact = term.replace(/\s+/g, '').trim();
|
||||||
|
if (!compact || !hasKanaOnly(compact)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return katakanaToHiragana(compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNameTerms(character: CharacterRecord): string[] {
|
||||||
|
const base = new Set<string>();
|
||||||
|
const rawNames = [character.nativeName, character.fullName];
|
||||||
|
for (const rawName of rawNames) {
|
||||||
|
const name = rawName.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
base.add(name);
|
||||||
|
|
||||||
|
const compact = name.replace(/[\s\u3000]+/g, '');
|
||||||
|
if (compact && compact !== name) {
|
||||||
|
base.add(compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noMiddleDots = compact.replace(/[・・·•]/g, '');
|
||||||
|
if (noMiddleDots && noMiddleDots !== compact) {
|
||||||
|
base.add(noMiddleDots);
|
||||||
|
}
|
||||||
|
|
||||||
|
const split = name.split(/[\s\u3000]+/).filter((part) => part.trim().length > 0);
|
||||||
|
if (split.length === 2) {
|
||||||
|
base.add(split[0]!);
|
||||||
|
base.add(split[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitByMiddleDot = name
|
||||||
|
.split(/[・・·•]/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
if (splitByMiddleDot.length >= 2) {
|
||||||
|
for (const part of splitByMiddleDot) {
|
||||||
|
base.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withHonorifics = new Set<string>();
|
||||||
|
for (const entry of base) {
|
||||||
|
withHonorifics.add(entry);
|
||||||
|
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||||
|
withHonorifics.add(`${entry}${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDescription(value: string): string {
|
||||||
|
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDescription(value: string): string {
|
||||||
|
const stripped = stripDescription(value);
|
||||||
|
if (!stripped) return '';
|
||||||
|
return stripped
|
||||||
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1')
|
||||||
|
.replace(/https?:\/\/\S+/g, '')
|
||||||
|
.replace(/__([^_]+)__/g, '$1')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||||
|
.replace(/~!/g, '')
|
||||||
|
.replace(/!~/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleInfo(role: CharacterDictionaryRole): { tag: string; score: number } {
|
||||||
|
if (role === 'main') return { tag: 'main', score: 100 };
|
||||||
|
if (role === 'primary') return { tag: 'primary', score: 75 };
|
||||||
|
if (role === 'side') return { tag: 'side', score: 50 };
|
||||||
|
return { tag: 'appears', score: 25 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
||||||
|
const value = (input || '').trim().toUpperCase();
|
||||||
|
if (value === 'MAIN') return 'main';
|
||||||
|
if (value === 'BACKGROUND') return 'appears';
|
||||||
|
if (value === 'SUPPORTING') return 'side';
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: CharacterDictionaryRole): string {
|
||||||
|
if (role === 'main') return 'Main';
|
||||||
|
if (role === 'primary') return 'Primary';
|
||||||
|
if (role === 'side') return 'Side';
|
||||||
|
return 'Appears';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageExt(contentType: string | null): string {
|
||||||
|
const normalized = (contentType || '').toLowerCase();
|
||||||
|
if (normalized.includes('png')) return 'png';
|
||||||
|
if (normalized.includes('gif')) return 'gif';
|
||||||
|
if (normalized.includes('webp')) return 'webp';
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dirPath: string): void {
|
||||||
|
if (fs.existsSync(dirPath)) return;
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandUserPath(input: string): string {
|
||||||
|
if (input.startsWith('~')) {
|
||||||
|
return path.join(os.homedir(), input.slice(1));
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoFile(filePath: string): boolean {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return VIDEO_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstVideoFileInDirectory(directoryPath: string): string | null {
|
||||||
|
const queue: string[] = [directoryPath];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(current, entry.name);
|
||||||
|
if (entry.isFile() && isVideoFile(fullPath)) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
||||||
|
queue.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDictionaryGuessInputs(targetPath: string): {
|
||||||
|
mediaPath: string;
|
||||||
|
mediaTitle: string | null;
|
||||||
|
} {
|
||||||
|
const trimmed = targetPath.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Dictionary target path is empty.');
|
||||||
|
}
|
||||||
|
const resolvedPath = path.resolve(expandUserPath(trimmed));
|
||||||
|
let stats: fs.Stats;
|
||||||
|
try {
|
||||||
|
stats = fs.statSync(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Dictionary target path not found: ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
return {
|
||||||
|
mediaPath: resolvedPath,
|
||||||
|
mediaTitle: path.basename(resolvedPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const firstVideo = findFirstVideoFileInDirectory(resolvedPath);
|
||||||
|
if (firstVideo) {
|
||||||
|
return {
|
||||||
|
mediaPath: firstVideo,
|
||||||
|
mediaTitle: path.basename(firstVideo),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mediaPath: resolvedPath,
|
||||||
|
mediaTitle: path.basename(resolvedPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Dictionary target must be a file or directory path: ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCache(cachePath: string): CharacterDictionaryCacheFile {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as CharacterDictionaryCacheFile;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || !parsed.anilistById) {
|
||||||
|
return { anilistById: {} };
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return { anilistById: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(cachePath: string, cache: CharacterDictionaryCacheFile): void {
|
||||||
|
ensureDir(path.dirname(cachePath));
|
||||||
|
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefinitionGlossary(
|
||||||
|
character: CharacterRecord,
|
||||||
|
mediaTitle: string,
|
||||||
|
imagePath: string | null,
|
||||||
|
): Array<string | Record<string, unknown>> {
|
||||||
|
const displayName = character.nativeName || character.fullName || `Character ${character.id}`;
|
||||||
|
const lines: string[] = [`${displayName} [${roleLabel(character.role)}]`, `${mediaTitle} · AniList`];
|
||||||
|
|
||||||
|
const description = normalizeDescription(character.description);
|
||||||
|
if (description) {
|
||||||
|
lines.push(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imagePath) {
|
||||||
|
return [lines.join('\n')];
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: Array<string | Record<string, unknown>> = [
|
||||||
|
{
|
||||||
|
tag: 'img',
|
||||||
|
path: imagePath,
|
||||||
|
width: 8,
|
||||||
|
height: 11,
|
||||||
|
sizeUnits: 'em',
|
||||||
|
title: displayName,
|
||||||
|
alt: displayName,
|
||||||
|
description: `${displayName} · ${mediaTitle}`,
|
||||||
|
collapsed: false,
|
||||||
|
collapsible: false,
|
||||||
|
background: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
if (i > 0) {
|
||||||
|
content.push({ tag: 'br' });
|
||||||
|
}
|
||||||
|
content.push(lines[i]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'structured-content',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTermEntry(
|
||||||
|
term: string,
|
||||||
|
reading: string,
|
||||||
|
role: CharacterDictionaryRole,
|
||||||
|
glossary: Array<string | Record<string, unknown>>,
|
||||||
|
): Array<string | number | Array<string | Record<string, unknown>>> {
|
||||||
|
const { tag, score } = roleInfo(role);
|
||||||
|
return [term, reading, `name ${tag}`, '', score, glossary, 0, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC32_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let i = 0; i < 256; i += 1) {
|
||||||
|
let crc = i;
|
||||||
|
for (let j = 0; j < 8; j += 1) {
|
||||||
|
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
||||||
|
}
|
||||||
|
table[i] = crc >>> 0;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(data: Buffer): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of data) {
|
||||||
|
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStoredZip(files: Array<{ name: string; data: Buffer }>): Buffer {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const entries: ZipEntry[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fileName = Buffer.from(file.name, 'utf8');
|
||||||
|
const fileData = file.data;
|
||||||
|
const fileCrc32 = crc32(fileData);
|
||||||
|
const local = Buffer.alloc(30 + fileName.length);
|
||||||
|
let cursor = 0;
|
||||||
|
local.writeUInt32LE(0x04034b50, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
local.writeUInt16LE(20, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt32LE(fileCrc32, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
local.writeUInt32LE(fileData.length, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
local.writeUInt32LE(fileData.length, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
local.writeUInt16LE(fileName.length, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
local.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
fileName.copy(local, cursor);
|
||||||
|
|
||||||
|
chunks.push(local, fileData);
|
||||||
|
entries.push({
|
||||||
|
name: file.name,
|
||||||
|
data: fileData,
|
||||||
|
crc32: fileCrc32,
|
||||||
|
localHeaderOffset: offset,
|
||||||
|
});
|
||||||
|
offset += local.length + fileData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralStart = offset;
|
||||||
|
const centralChunks: Buffer[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fileName = Buffer.from(entry.name, 'utf8');
|
||||||
|
const central = Buffer.alloc(46 + fileName.length);
|
||||||
|
let cursor = 0;
|
||||||
|
central.writeUInt32LE(0x02014b50, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
central.writeUInt16LE(20, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(20, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt32LE(entry.crc32, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
central.writeUInt32LE(entry.data.length, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
central.writeUInt32LE(entry.data.length, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
central.writeUInt16LE(fileName.length, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
central.writeUInt32LE(0, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
central.writeUInt32LE(entry.localHeaderOffset, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
fileName.copy(central, cursor);
|
||||||
|
centralChunks.push(central);
|
||||||
|
offset += central.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralSize = offset - centralStart;
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
let cursor = 0;
|
||||||
|
end.writeUInt32LE(0x06054b50, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
end.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
end.writeUInt16LE(0, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
end.writeUInt16LE(entries.length, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
end.writeUInt16LE(entries.length, cursor);
|
||||||
|
cursor += 2;
|
||||||
|
end.writeUInt32LE(centralSize, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
end.writeUInt32LE(centralStart, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
end.writeUInt16LE(0, cursor);
|
||||||
|
|
||||||
|
return Buffer.concat([...chunks, ...centralChunks, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAniList<T>(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(ANILIST_GRAPHQL_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`AniList request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
data?: T;
|
||||||
|
errors?: Array<{ message?: string }>;
|
||||||
|
};
|
||||||
|
const firstError = payload.errors?.find((entry) => entry && typeof entry.message === 'string');
|
||||||
|
if (firstError?.message) {
|
||||||
|
throw new Error(firstError.message);
|
||||||
|
}
|
||||||
|
if (!payload.data) {
|
||||||
|
throw new Error('AniList response missing data');
|
||||||
|
}
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAniListMediaIdFromGuess(
|
||||||
|
guess: AnilistMediaGuess,
|
||||||
|
): Promise<ResolvedAniListMedia> {
|
||||||
|
const data = await fetchAniList<AniListSearchResponse>(
|
||||||
|
`
|
||||||
|
query($search: String!) {
|
||||||
|
Page(perPage: 10) {
|
||||||
|
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||||
|
id
|
||||||
|
episodes
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
search: guess.title,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const media = data.Page?.media ?? [];
|
||||||
|
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
|
||||||
|
if (!resolved) {
|
||||||
|
throw new Error(`No AniList media match found for "${guess.title}".`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCharactersForMedia(mediaId: number): Promise<{
|
||||||
|
mediaTitle: string;
|
||||||
|
characters: CharacterRecord[];
|
||||||
|
}> {
|
||||||
|
const characters: CharacterRecord[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let mediaTitle = '';
|
||||||
|
for (;;) {
|
||||||
|
const data = await fetchAniList<AniListCharacterPageResponse>(
|
||||||
|
`
|
||||||
|
query($id: Int!, $page: Int!) {
|
||||||
|
Media(id: $id, type: ANIME) {
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
|
characters(page: $page, perPage: 50, sort: [ROLE, RELEVANCE, ID]) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
description(asHtml: false)
|
||||||
|
image {
|
||||||
|
large
|
||||||
|
medium
|
||||||
|
}
|
||||||
|
name {
|
||||||
|
full
|
||||||
|
native
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
id: mediaId,
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const media = data.Media;
|
||||||
|
if (!media) {
|
||||||
|
throw new Error(`AniList media ${mediaId} not found.`);
|
||||||
|
}
|
||||||
|
if (!mediaTitle) {
|
||||||
|
mediaTitle =
|
||||||
|
media.title?.english?.trim() ||
|
||||||
|
media.title?.romaji?.trim() ||
|
||||||
|
media.title?.native?.trim() ||
|
||||||
|
`AniList ${mediaId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges = media.characters?.edges ?? [];
|
||||||
|
for (const edge of edges) {
|
||||||
|
const node = edge?.node;
|
||||||
|
if (!node || typeof node.id !== 'number') continue;
|
||||||
|
const fullName = node.name?.full?.trim() || '';
|
||||||
|
const nativeName = node.name?.native?.trim() || '';
|
||||||
|
if (!fullName && !nativeName) continue;
|
||||||
|
characters.push({
|
||||||
|
id: node.id,
|
||||||
|
role: mapRole(edge?.role),
|
||||||
|
fullName,
|
||||||
|
nativeName,
|
||||||
|
description: node.description || '',
|
||||||
|
imageUrl: node.image?.large || node.image?.medium || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNextPage = Boolean(media.characters?.pageInfo?.hasNextPage);
|
||||||
|
if (!hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
page += 1;
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaTitle,
|
||||||
|
characters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCharacterImage(imageUrl: string, charId: number): Promise<{
|
||||||
|
filename: string;
|
||||||
|
bytes: Buffer;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (bytes.length === 0) return null;
|
||||||
|
const ext = inferImageExt(response.headers.get('content-type'));
|
||||||
|
return {
|
||||||
|
filename: `c${charId}.${ext}`,
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDictionaryTitle(mediaId: number): string {
|
||||||
|
return `SubMiner Character Dictionary (AniList ${mediaId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIndex(mediaId: number, mediaTitle: string, revision: string): Record<string, unknown> {
|
||||||
|
const dictionaryTitle = buildDictionaryTitle(mediaId);
|
||||||
|
return {
|
||||||
|
title: dictionaryTitle,
|
||||||
|
revision,
|
||||||
|
format: 3,
|
||||||
|
author: 'SubMiner',
|
||||||
|
description: `Character names from ${mediaTitle} [AniList media ID ${mediaId}]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTagBank(): Array<[string, string, number, string, number]> {
|
||||||
|
return [
|
||||||
|
['name', 'partOfSpeech', 0, 'Character name', 0],
|
||||||
|
['main', 'name', 0, 'Protagonist', 0],
|
||||||
|
['primary', 'name', 0, 'Main character', 0],
|
||||||
|
['side', 'name', 0, 'Side character', 0],
|
||||||
|
['appears', 'name', 0, 'Minor appearance', 0],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
|
||||||
|
generateForCurrentMedia: (
|
||||||
|
targetPath?: string,
|
||||||
|
options?: CharacterDictionaryGenerateOptions,
|
||||||
|
) => Promise<CharacterDictionaryBuildResult>;
|
||||||
|
} {
|
||||||
|
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||||
|
const cachePath = path.join(outputDir, 'cache.json');
|
||||||
|
|
||||||
|
return {
|
||||||
|
generateForCurrentMedia: async (
|
||||||
|
targetPath?: string,
|
||||||
|
options?: CharacterDictionaryGenerateOptions,
|
||||||
|
) => {
|
||||||
|
const dictionaryTarget = targetPath?.trim() || '';
|
||||||
|
const guessInput =
|
||||||
|
dictionaryTarget.length > 0
|
||||||
|
? resolveDictionaryGuessInputs(dictionaryTarget)
|
||||||
|
: {
|
||||||
|
mediaPath: deps.getCurrentMediaPath(),
|
||||||
|
mediaTitle: deps.getCurrentMediaTitle(),
|
||||||
|
};
|
||||||
|
const mediaPathForGuess = deps.resolveMediaPathForJimaku(guessInput.mediaPath);
|
||||||
|
const mediaTitle = guessInput.mediaTitle;
|
||||||
|
const guessed = await deps.guessAnilistMediaInfo(mediaPathForGuess, mediaTitle);
|
||||||
|
if (!guessed || !guessed.title.trim()) {
|
||||||
|
throw new Error('Unable to resolve current anime from media path/title.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMedia = await resolveAniListMediaIdFromGuess(guessed);
|
||||||
|
const cache = readCache(cachePath);
|
||||||
|
const cached = cache.anilistById[String(resolvedMedia.id)];
|
||||||
|
const refreshTtlMsRaw = options?.refreshTtlMs;
|
||||||
|
const hasRefreshTtl =
|
||||||
|
typeof refreshTtlMsRaw === 'number' && Number.isFinite(refreshTtlMsRaw) && refreshTtlMsRaw > 0;
|
||||||
|
const now = deps.now();
|
||||||
|
const cacheAgeMs =
|
||||||
|
cached && typeof cached.updatedAt === 'number' && Number.isFinite(cached.updatedAt)
|
||||||
|
? Math.max(0, now - cached.updatedAt)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const isCacheFresh = !hasRefreshTtl || cacheAgeMs <= refreshTtlMsRaw;
|
||||||
|
const isCacheFormatCurrent =
|
||||||
|
cached?.formatVersion === undefined
|
||||||
|
? false
|
||||||
|
: cached.formatVersion >= CHARACTER_DICTIONARY_FORMAT_VERSION;
|
||||||
|
if (cached?.zipPath && fs.existsSync(cached.zipPath) && isCacheFresh && isCacheFormatCurrent) {
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] cache hit for AniList ${resolvedMedia.id}: ${path.basename(cached.zipPath)}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
zipPath: cached.zipPath,
|
||||||
|
fromCache: true,
|
||||||
|
mediaId: cached.mediaId,
|
||||||
|
mediaTitle: cached.mediaTitle,
|
||||||
|
entryCount: cached.entryCount,
|
||||||
|
dictionaryTitle: cached.dictionaryTitle ?? buildDictionaryTitle(cached.mediaId),
|
||||||
|
revision: cached.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
|
||||||
|
resolvedMedia.id,
|
||||||
|
);
|
||||||
|
if (characters.length === 0) {
|
||||||
|
throw new Error(`No characters returned for AniList media ${resolvedMedia.id}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(outputDir);
|
||||||
|
const zipFiles: Array<{ name: string; data: Buffer }> = [];
|
||||||
|
const termEntries: Array<Array<string | number | Array<string | Record<string, unknown>>>> =
|
||||||
|
[];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const character of characters) {
|
||||||
|
let imagePath: string | null = null;
|
||||||
|
if (character.imageUrl) {
|
||||||
|
const image = await downloadCharacterImage(character.imageUrl, character.id);
|
||||||
|
if (image) {
|
||||||
|
imagePath = `img/${image.filename}`;
|
||||||
|
zipFiles.push({
|
||||||
|
name: imagePath,
|
||||||
|
data: image.bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const glossary = createDefinitionGlossary(character, fetchedMediaTitle, imagePath);
|
||||||
|
const candidateTerms = buildNameTerms(character);
|
||||||
|
for (const term of candidateTerms) {
|
||||||
|
const reading = buildReading(term);
|
||||||
|
const dedupeKey = `${term}|${reading}|${character.role}`;
|
||||||
|
if (seen.has(dedupeKey)) continue;
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
termEntries.push(buildTermEntry(term, reading, character.role, glossary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (termEntries.length === 0) {
|
||||||
|
throw new Error('No dictionary entries generated from AniList character data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = String(now);
|
||||||
|
const dictionaryTitle = buildDictionaryTitle(resolvedMedia.id);
|
||||||
|
zipFiles.push({
|
||||||
|
name: 'index.json',
|
||||||
|
data: Buffer.from(
|
||||||
|
JSON.stringify(createIndex(resolvedMedia.id, fetchedMediaTitle, revision), null, 2),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
zipFiles.push({
|
||||||
|
name: 'tag_bank_1.json',
|
||||||
|
data: Buffer.from(JSON.stringify(createTagBank()), 'utf8'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const entriesPerBank = 10_000;
|
||||||
|
for (let i = 0; i < termEntries.length; i += entriesPerBank) {
|
||||||
|
const chunk = termEntries.slice(i, i + entriesPerBank);
|
||||||
|
zipFiles.push({
|
||||||
|
name: `term_bank_${Math.floor(i / entriesPerBank) + 1}.json`,
|
||||||
|
data: Buffer.from(JSON.stringify(chunk), 'utf8'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBuffer = createStoredZip(zipFiles);
|
||||||
|
const zipPath = path.join(outputDir, `anilist-${resolvedMedia.id}.zip`);
|
||||||
|
fs.writeFileSync(zipPath, zipBuffer);
|
||||||
|
|
||||||
|
const cacheEntry: CharacterDictionaryCacheEntry = {
|
||||||
|
mediaId: resolvedMedia.id,
|
||||||
|
mediaTitle: fetchedMediaTitle,
|
||||||
|
entryCount: termEntries.length,
|
||||||
|
zipPath,
|
||||||
|
updatedAt: now,
|
||||||
|
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||||
|
dictionaryTitle,
|
||||||
|
revision,
|
||||||
|
};
|
||||||
|
cache.anilistById[String(resolvedMedia.id)] = cacheEntry;
|
||||||
|
writeCache(cachePath, cache);
|
||||||
|
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] generated AniList ${resolvedMedia.id}: ${termEntries.length} terms -> ${zipPath}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zipPath,
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: resolvedMedia.id,
|
||||||
|
mediaTitle: fetchedMediaTitle,
|
||||||
|
entryCount: termEntries.length,
|
||||||
|
dictionaryTitle,
|
||||||
|
revision,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||||
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams['anilist']['getQueueStatus'];
|
||||||
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams['anilist']['retryQueueNow'];
|
||||||
|
generateCharacterDictionary: CliCommandRuntimeServiceDepsParams['dictionary']['generate'];
|
||||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
@@ -94,6 +95,9 @@ function createCliCommandDepsFromContext(
|
|||||||
getQueueStatus: context.getAnilistQueueStatus,
|
getQueueStatus: context.getAnilistQueueStatus,
|
||||||
retryQueueNow: context.retryAnilistQueueNow,
|
retryQueueNow: context.retryAnilistQueueNow,
|
||||||
},
|
},
|
||||||
|
dictionary: {
|
||||||
|
generate: context.generateCharacterDictionary,
|
||||||
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
openSetup: context.openJellyfinSetup,
|
openSetup: context.openJellyfinSetup,
|
||||||
runCommand: context.runJellyfinCommand,
|
runCommand: context.runJellyfinCommand,
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
|
getQueueStatus: CliCommandDepsRuntimeOptions['anilist']['getQueueStatus'];
|
||||||
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
|
retryQueueNow: CliCommandDepsRuntimeOptions['anilist']['retryQueueNow'];
|
||||||
};
|
};
|
||||||
|
dictionary: {
|
||||||
|
generate: CliCommandDepsRuntimeOptions['dictionary']['generate'];
|
||||||
|
};
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
openSetup: CliCommandDepsRuntimeOptions['jellyfin']['openSetup'];
|
||||||
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
|
||||||
@@ -296,6 +299,9 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
getQueueStatus: params.anilist.getQueueStatus,
|
getQueueStatus: params.anilist.getQueueStatus,
|
||||||
retryQueueNow: params.anilist.retryQueueNow,
|
retryQueueNow: params.anilist.retryQueueNow,
|
||||||
},
|
},
|
||||||
|
dictionary: {
|
||||||
|
generate: params.dictionary.generate,
|
||||||
|
},
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
openSetup: params.jellyfin.openSetup,
|
openSetup: params.jellyfin.openSetup,
|
||||||
runCommand: params.jellyfin.runCommand,
|
runCommand: params.jellyfin.runCommand,
|
||||||
|
|||||||
221
src/main/runtime/character-dictionary-auto-sync.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createCharacterDictionaryAutoSyncRuntimeService } from './character-dictionary-auto-sync';
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('auto sync imports current dictionary and updates persisted state', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const imported: string[] = [];
|
||||||
|
const upserts: Array<{ title: string; scope: 'all' | 'active' }> = [];
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
refreshTtlHours: 168,
|
||||||
|
maxLoaded: 3,
|
||||||
|
evictionPolicy: 'delete',
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
generateCharacterDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/anilist-130298.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 2544,
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
|
||||||
|
revision: '100',
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () => [],
|
||||||
|
importYomitanDictionary: async (zipPath) => {
|
||||||
|
imported.push(zipPath);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||||
|
upserts.push({ title: dictionaryTitle, scope: profileScope });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
removeYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.deepEqual(imported, ['/tmp/anilist-130298.zip']);
|
||||||
|
assert.deepEqual(upserts, [
|
||||||
|
{ title: 'SubMiner Character Dictionary (AniList 130298)', scope: 'all' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||||
|
activeMediaIds: number[];
|
||||||
|
dictionariesByMediaId: Record<string, { lastImportedRevision: string }>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(state.activeMediaIds, [130298]);
|
||||||
|
assert.equal(state.dictionariesByMediaId['130298']?.lastImportedRevision, '100');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync rotates dictionaries by LRU and deletes overflow when policy=delete', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const generated = [
|
||||||
|
{ mediaId: 1, zipPath: '/tmp/anilist-1.zip', title: 'SubMiner Character Dictionary (AniList 1)' },
|
||||||
|
{ mediaId: 2, zipPath: '/tmp/anilist-2.zip', title: 'SubMiner Character Dictionary (AniList 2)' },
|
||||||
|
{ mediaId: 3, zipPath: '/tmp/anilist-3.zip', title: 'SubMiner Character Dictionary (AniList 3)' },
|
||||||
|
{ mediaId: 4, zipPath: '/tmp/anilist-4.zip', title: 'SubMiner Character Dictionary (AniList 4)' },
|
||||||
|
];
|
||||||
|
let runIndex = 0;
|
||||||
|
const deletes: string[] = [];
|
||||||
|
const removals: Array<{ title: string; mode: 'delete' | 'disable' }> = [];
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
refreshTtlHours: 168,
|
||||||
|
maxLoaded: 3,
|
||||||
|
evictionPolicy: 'delete',
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
generateCharacterDictionary: async () => {
|
||||||
|
const current = generated[Math.min(runIndex, generated.length - 1)]!;
|
||||||
|
runIndex += 1;
|
||||||
|
return {
|
||||||
|
zipPath: current.zipPath,
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: current.mediaId,
|
||||||
|
mediaTitle: `Title ${current.mediaId}`,
|
||||||
|
entryCount: 10,
|
||||||
|
dictionaryTitle: current.title,
|
||||||
|
revision: String(current.mediaId),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getYomitanDictionaryInfo: async () => [],
|
||||||
|
importYomitanDictionary: async () => true,
|
||||||
|
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||||
|
deletes.push(dictionaryTitle);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
removeYomitanDictionarySettings: async (dictionaryTitle, _scope, mode) => {
|
||||||
|
removals.push({ title: dictionaryTitle, mode });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
now: () => Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.ok(removals.some((entry) => entry.title.includes('(AniList 1)') && entry.mode === 'delete'));
|
||||||
|
assert.ok(deletes.some((title) => title.includes('(AniList 1)')));
|
||||||
|
|
||||||
|
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||||
|
activeMediaIds: number[];
|
||||||
|
dictionariesByMediaId: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||||
|
assert.equal(state.dictionariesByMediaId['1'], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync disable eviction keeps dictionary in DB and only disables settings', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
let runIndex = 0;
|
||||||
|
const deletes: string[] = [];
|
||||||
|
const removals: Array<{ title: string; mode: 'delete' | 'disable' }> = [];
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
refreshTtlHours: 168,
|
||||||
|
maxLoaded: 1,
|
||||||
|
evictionPolicy: 'disable',
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
generateCharacterDictionary: async () => {
|
||||||
|
runIndex += 1;
|
||||||
|
return {
|
||||||
|
zipPath: `/tmp/anilist-${runIndex}.zip`,
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: runIndex,
|
||||||
|
mediaTitle: `Title ${runIndex}`,
|
||||||
|
entryCount: 10,
|
||||||
|
dictionaryTitle: `SubMiner Character Dictionary (AniList ${runIndex})`,
|
||||||
|
revision: String(runIndex),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getYomitanDictionaryInfo: async () => [],
|
||||||
|
importYomitanDictionary: async () => true,
|
||||||
|
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||||
|
deletes.push(dictionaryTitle);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
removeYomitanDictionarySettings: async (dictionaryTitle, _scope, mode) => {
|
||||||
|
removals.push({ title: dictionaryTitle, mode });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
now: () => Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.ok(removals.some((entry) => entry.mode === 'disable' && entry.title.includes('(AniList 1)')));
|
||||||
|
assert.equal(deletes.some((title) => title.includes('(AniList 1)')), false);
|
||||||
|
|
||||||
|
const statePath = path.join(userDataPath, 'character-dictionaries', 'auto-sync-state.json');
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8')) as {
|
||||||
|
activeMediaIds: number[];
|
||||||
|
dictionariesByMediaId: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
assert.deepEqual(state.activeMediaIds, [2]);
|
||||||
|
assert.ok(state.dictionariesByMediaId['1']);
|
||||||
|
assert.ok(state.dictionariesByMediaId['2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync fails fast when yomitan import hangs', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
operationTimeoutMs: 5,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
refreshTtlHours: 168,
|
||||||
|
maxLoaded: 3,
|
||||||
|
evictionPolicy: 'delete',
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
generateCharacterDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/anilist-130298.zip',
|
||||||
|
fromCache: true,
|
||||||
|
mediaId: 130298,
|
||||||
|
mediaTitle: 'The Eminence in Shadow',
|
||||||
|
entryCount: 2544,
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary (AniList 130298)',
|
||||||
|
revision: '100',
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () => [],
|
||||||
|
importYomitanDictionary: async () =>
|
||||||
|
new Promise<boolean>(() => {
|
||||||
|
// never resolve
|
||||||
|
}),
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
removeYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(async () => runtime.runSyncNow(), /importYomitanDictionary\(anilist-130298\.zip\) timed out after 5ms/);
|
||||||
|
});
|
||||||
308
src/main/runtime/character-dictionary-auto-sync.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type {
|
||||||
|
AnilistCharacterDictionaryEvictionPolicy,
|
||||||
|
AnilistCharacterDictionaryProfileScope,
|
||||||
|
} from '../../types';
|
||||||
|
import type {
|
||||||
|
CharacterDictionaryBuildResult,
|
||||||
|
CharacterDictionaryGenerateOptions,
|
||||||
|
} from '../character-dictionary-runtime';
|
||||||
|
|
||||||
|
type AutoSyncStateDictionaryEntry = {
|
||||||
|
mediaId: number;
|
||||||
|
dictionaryTitle: string;
|
||||||
|
lastImportedRevision: string | null;
|
||||||
|
lastUsedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoSyncState = {
|
||||||
|
activeMediaIds: number[];
|
||||||
|
dictionariesByMediaId: Record<string, AutoSyncStateDictionaryEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoSyncDictionaryInfo = {
|
||||||
|
title: string;
|
||||||
|
revision?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CharacterDictionaryAutoSyncConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
refreshTtlHours: number;
|
||||||
|
maxLoaded: number;
|
||||||
|
evictionPolicy: AnilistCharacterDictionaryEvictionPolicy;
|
||||||
|
profileScope: AnilistCharacterDictionaryProfileScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
getConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||||
|
generateCharacterDictionary: (
|
||||||
|
options?: CharacterDictionaryGenerateOptions,
|
||||||
|
) => Promise<CharacterDictionaryBuildResult>;
|
||||||
|
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||||
|
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||||
|
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||||
|
upsertYomitanDictionarySettings: (
|
||||||
|
dictionaryTitle: string,
|
||||||
|
profileScope: AnilistCharacterDictionaryProfileScope,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
removeYomitanDictionarySettings: (
|
||||||
|
dictionaryTitle: string,
|
||||||
|
profileScope: AnilistCharacterDictionaryProfileScope,
|
||||||
|
mode: 'delete' | 'disable',
|
||||||
|
) => Promise<boolean>;
|
||||||
|
now: () => number;
|
||||||
|
schedule?: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearSchedule?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||||
|
operationTimeoutMs?: number;
|
||||||
|
logInfo?: (message: string) => void;
|
||||||
|
logWarn?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(dirPath: string): void {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAutoSyncState(statePath: string): AutoSyncState {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(statePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<AutoSyncState>;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return { activeMediaIds: [], dictionariesByMediaId: {} };
|
||||||
|
}
|
||||||
|
const dictionariesByMediaId = parsed.dictionariesByMediaId ?? {};
|
||||||
|
if (!dictionariesByMediaId || typeof dictionariesByMediaId !== 'object') {
|
||||||
|
return { activeMediaIds: [], dictionariesByMediaId: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEntries: Record<string, AutoSyncStateDictionaryEntry> = {};
|
||||||
|
for (const [key, value] of Object.entries(dictionariesByMediaId)) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const mediaId = Number.parseInt(key, 10);
|
||||||
|
const dictionaryTitle =
|
||||||
|
typeof (value as { dictionaryTitle?: unknown }).dictionaryTitle === 'string'
|
||||||
|
? (value as { dictionaryTitle: string }).dictionaryTitle.trim()
|
||||||
|
: '';
|
||||||
|
if (!Number.isFinite(mediaId) || mediaId <= 0 || !dictionaryTitle) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastImportedRevisionRaw = (value as { lastImportedRevision?: unknown })
|
||||||
|
.lastImportedRevision;
|
||||||
|
const lastUsedAtRaw = (value as { lastUsedAt?: unknown }).lastUsedAt;
|
||||||
|
normalizedEntries[String(mediaId)] = {
|
||||||
|
mediaId,
|
||||||
|
dictionaryTitle,
|
||||||
|
lastImportedRevision:
|
||||||
|
typeof lastImportedRevisionRaw === 'string' && lastImportedRevisionRaw.length > 0
|
||||||
|
? lastImportedRevisionRaw
|
||||||
|
: null,
|
||||||
|
lastUsedAt:
|
||||||
|
typeof lastUsedAtRaw === 'number' && Number.isFinite(lastUsedAtRaw) ? lastUsedAtRaw : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMediaIdsRaw = Array.isArray(parsed.activeMediaIds) ? parsed.activeMediaIds : [];
|
||||||
|
const activeMediaIds = activeMediaIdsRaw
|
||||||
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||||
|
.map((value) => Math.max(1, Math.floor(value)))
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.filter((value) => normalizedEntries[String(value)] !== undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeMediaIds,
|
||||||
|
dictionariesByMediaId: normalizedEntries,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { activeMediaIds: [], dictionariesByMediaId: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAutoSyncState(statePath: string, state: AutoSyncState): void {
|
||||||
|
ensureDir(path.dirname(statePath));
|
||||||
|
fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDictionaryTitle(mediaId: number): string {
|
||||||
|
return `SubMiner Character Dictionary (AniList ${mediaId})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||||
|
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
||||||
|
): {
|
||||||
|
scheduleSync: () => void;
|
||||||
|
runSyncNow: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const dictionariesDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||||
|
const statePath = path.join(dictionariesDir, 'auto-sync-state.json');
|
||||||
|
const schedule = deps.schedule ?? ((fn, delayMs) => setTimeout(fn, delayMs));
|
||||||
|
const clearSchedule = deps.clearSchedule ?? ((timer) => clearTimeout(timer));
|
||||||
|
const debounceMs = 800;
|
||||||
|
const operationTimeoutMs = Math.max(1, Math.floor(deps.operationTimeoutMs ?? 7_000));
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let syncInFlight = false;
|
||||||
|
let runQueued = false;
|
||||||
|
|
||||||
|
const withOperationTimeout = async <T>(label: string, promise: Promise<T>): Promise<T> => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(new Error(`${label} timed out after ${operationTimeoutMs}ms`));
|
||||||
|
}, operationTimeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSyncOnce = async (): Promise<void> => {
|
||||||
|
const config = deps.getConfig();
|
||||||
|
if (!config.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTtlMs = Math.max(1, Math.floor(config.refreshTtlHours)) * 60 * 60 * 1000;
|
||||||
|
const generation = await deps.generateCharacterDictionary({ refreshTtlMs });
|
||||||
|
const dictionaryTitle = generation.dictionaryTitle ?? buildDictionaryTitle(generation.mediaId);
|
||||||
|
const revision =
|
||||||
|
typeof generation.revision === 'string' && generation.revision.length > 0
|
||||||
|
? generation.revision
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const state = readAutoSyncState(statePath);
|
||||||
|
const dictionaryInfo = await withOperationTimeout(
|
||||||
|
'getYomitanDictionaryInfo',
|
||||||
|
deps.getYomitanDictionaryInfo(),
|
||||||
|
);
|
||||||
|
const existing = dictionaryInfo.find((entry) => entry.title === dictionaryTitle) ?? null;
|
||||||
|
const existingRevision =
|
||||||
|
existing && (typeof existing.revision === 'string' || typeof existing.revision === 'number')
|
||||||
|
? String(existing.revision)
|
||||||
|
: null;
|
||||||
|
const shouldImport =
|
||||||
|
existing === null || (revision !== null && existingRevision !== revision);
|
||||||
|
|
||||||
|
if (shouldImport) {
|
||||||
|
if (existing !== null) {
|
||||||
|
await withOperationTimeout(
|
||||||
|
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||||
|
deps.deleteYomitanDictionary(dictionaryTitle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary:auto-sync] importing AniList ${generation.mediaId}: ${generation.zipPath}`,
|
||||||
|
);
|
||||||
|
const imported = await withOperationTimeout(
|
||||||
|
`importYomitanDictionary(${path.basename(generation.zipPath)})`,
|
||||||
|
deps.importYomitanDictionary(generation.zipPath),
|
||||||
|
);
|
||||||
|
if (!imported) {
|
||||||
|
throw new Error(`Failed to import dictionary ZIP: ${generation.zipPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await withOperationTimeout(
|
||||||
|
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
||||||
|
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaIdKey = String(generation.mediaId);
|
||||||
|
state.dictionariesByMediaId[mediaIdKey] = {
|
||||||
|
mediaId: generation.mediaId,
|
||||||
|
dictionaryTitle,
|
||||||
|
lastImportedRevision: revision,
|
||||||
|
lastUsedAt: deps.now(),
|
||||||
|
};
|
||||||
|
state.activeMediaIds = [
|
||||||
|
generation.mediaId,
|
||||||
|
...state.activeMediaIds.filter((value) => value !== generation.mediaId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxLoaded = Math.max(1, Math.floor(config.maxLoaded));
|
||||||
|
while (state.activeMediaIds.length > maxLoaded) {
|
||||||
|
const evictedMediaId = state.activeMediaIds.pop();
|
||||||
|
if (evictedMediaId === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const evicted = state.dictionariesByMediaId[String(evictedMediaId)];
|
||||||
|
if (!evicted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withOperationTimeout(
|
||||||
|
`removeYomitanDictionarySettings(${evicted.dictionaryTitle})`,
|
||||||
|
deps.removeYomitanDictionarySettings(
|
||||||
|
evicted.dictionaryTitle,
|
||||||
|
config.profileScope,
|
||||||
|
config.evictionPolicy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (config.evictionPolicy === 'delete') {
|
||||||
|
await withOperationTimeout(
|
||||||
|
`deleteYomitanDictionary(${evicted.dictionaryTitle})`,
|
||||||
|
deps.deleteYomitanDictionary(evicted.dictionaryTitle),
|
||||||
|
);
|
||||||
|
delete state.dictionariesByMediaId[String(evictedMediaId)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAutoSyncState(statePath, state);
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary:auto-sync] synced AniList ${generation.mediaId}: ${dictionaryTitle} (${generation.entryCount} entries)`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueSync = (): void => {
|
||||||
|
runQueued = true;
|
||||||
|
if (syncInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncInFlight = true;
|
||||||
|
void (async () => {
|
||||||
|
while (runQueued) {
|
||||||
|
runQueued = false;
|
||||||
|
try {
|
||||||
|
await runSyncOnce();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn?.(
|
||||||
|
`[dictionary:auto-sync] sync failed: ${(error as Error)?.message ?? String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
syncInFlight = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleSync: () => {
|
||||||
|
const config = deps.getConfig();
|
||||||
|
if (!config.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (debounceTimer !== null) {
|
||||||
|
clearSchedule(debounceTimer);
|
||||||
|
}
|
||||||
|
debounceTimer = schedule(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
enqueueSync();
|
||||||
|
}, debounceMs);
|
||||||
|
},
|
||||||
|
runSyncNow: async () => {
|
||||||
|
await runSyncOnce();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,6 +46,13 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||||
getAnilistQueueStatus: () => ({}) as never,
|
getAnilistQueueStatus: () => ({}) as never,
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
generateCharacterDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/anilist-1.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 1,
|
||||||
|
mediaTitle: 'Test',
|
||||||
|
entryCount: 10,
|
||||||
|
}),
|
||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('run-jellyfin');
|
calls.push('run-jellyfin');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
|
openJellyfinSetup: CliCommandContextFactoryDeps['openJellyfinSetup'];
|
||||||
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
|
||||||
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
retryAnilistQueueNow: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
|
||||||
|
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
cycleSecondarySubMode: () => void;
|
cycleSecondarySubMode: () => void;
|
||||||
@@ -76,6 +77,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
openJellyfinSetup: deps.openJellyfinSetup,
|
openJellyfinSetup: deps.openJellyfinSetup,
|
||||||
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
getAnilistQueueStatus: deps.getAnilistQueueStatus,
|
||||||
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
retryAnilistQueueNow: deps.retryAnilistQueueNow,
|
||||||
|
generateCharacterDictionary: deps.generateCharacterDictionary,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||||
|
|||||||