mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c08bea5f7 | |||
| 01e679eab6 | |||
| f57bd107b9 | |||
| aa5d193e55 | |||
| da5bc7ab11 | |||
| b0857054eb | |||
| f721bf6609 | |||
| 2aecd63850 | |||
| f35520029f | |||
| 95994fcd3a | |||
| 999f12e958 | |||
| 260afac294 | |||
| 678a958351 | |||
| 608fc96c4e | |||
| 23ccac4836 | |||
| a875f35f1a | |||
| e2cf9afbf9 | |||
| 3f493ac716 | |||
| e64ef7f95c | |||
| c118b5b8dd | |||
| 6535ec6932 | |||
| fba5d09c75 | |||
| b88d29967d | |||
| 1b87d20a27 | |||
| 7bd3158b05 | |||
| 7a4fa1e5bf | |||
| f7d9199500 | |||
| 01cd5d1428 | |||
| e2270602a3 | |||
| 8b1e5b2ee7 |
@@ -23,3 +23,4 @@ betterseqtaplus-safari/
|
|||||||
.env
|
.env
|
||||||
.env.submit
|
.env.submit
|
||||||
dependency-graph.svg
|
dependency-graph.svg
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bedframe/core": "^0.0.46",
|
"@bedframe/core": "^0.1.0",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"@codemirror/search": "^6.5.10",
|
"@codemirror/search": "^6.5.10",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.4",
|
"@codemirror/view": "^6.36.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/chrome": "^0.1.4",
|
"@types/chrome": "^0.1.4",
|
||||||
@@ -52,17 +52,17 @@
|
|||||||
"react-dom": "17",
|
"react-dom": "17",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte": "^5.22.6",
|
"svelte": "^5.46.4",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vite": "^6.2.1",
|
"vite": "^8.0.5",
|
||||||
"webextension-polyfill": "^0.12.0",
|
"webextension-polyfill": "^0.12.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-transform-runtime": "^7.26.9",
|
"@babel/plugin-transform-runtime": "^7.26.9",
|
||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.0.95",
|
"@bedframe/cli": "^0.1.2",
|
||||||
"@crxjs/vite-plugin": "^2.2.0",
|
"@crxjs/vite-plugin": "^2.4.0",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -127,9 +127,9 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||||
|
|
||||||
"@bedframe/cli": ["@bedframe/cli@0.0.95", "", { "dependencies": { "@bedframe/core": "0.0.46", "commander": "14.0.0", "execa": "9.6.0", "kolorist": "1.8.0", "listr": "0.14.3", "nanospinner": "1.2.2", "node-fetch": "3.3.2", "pkg-install": "1.0.0", "prompts": "2.4.2", "vite": "6.3.5" }, "peerDependencies": { "concurrently": "8.2.2" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-WSb0HhHCfH7/tS5dDC/HL/VgKrIFGLmI0OesmcwQntrJdHVirtjrDVjcTFG3lC3LB5Ax85P1CY50Gy5aDNc0sQ=="],
|
"@bedframe/cli": ["@bedframe/cli@0.1.2", "", { "dependencies": { "@bedframe/core": "0.1.0", "commander": "^14.0.2", "execa": "^9.6.1", "kolorist": "^1.8.0", "listr": "^0.14.3", "nanospinner": "^1.2.2", "node-fetch": "^3.3.2", "pkg-install": "^1.0.0", "prompts": "^2.4.2", "vite": "^6.4.1" }, "peerDependencies": { "concurrently": "^8.2.1" }, "bin": { "bedframe": "dist/bedframe.js", "create-bedframe": "dist/create-bedframe.js" } }, "sha512-nu0VSfGLhY9f62w+fDRQi2YnfoY9c6u28ZlJ8rH6f57ItLo5TNrZetfw37fYNnh8yK2RSAWU7+6KCkdVm0Fokg=="],
|
||||||
|
|
||||||
"@bedframe/core": ["@bedframe/core@0.0.46", "", { "dependencies": { "@crxjs/vite-plugin": "2.0.2" }, "peerDependencies": { "vite-plugin-dts": "3.9.1", "vite-plugin-externalize-deps": "0.7.0", "vitest": "0.34.6" } }, "sha512-cOshFUrBksWnVQ08chunlvAetwhuytkX7NdH6blNNylYzsgCaLGBbCJ2EZ0d18kimFVNZoODrc+812if5dio/w=="],
|
"@bedframe/core": ["@bedframe/core@0.1.0", "", { "dependencies": { "@crxjs/vite-plugin": "2.3.0" }, "peerDependencies": { "vite-plugin-dts": "^3.7.0", "vite-plugin-externalize-deps": "^0.7.0", "vitest": "^0.34.6" } }, "sha512-bM9vuYG67m9lVTui966AmkoxPPdEHEDOKKjzAWV/Ymgur818fRhMMpblx3+PLs8kTCek1m79fjYKoE8PhqJ22g=="],
|
||||||
|
|
||||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
|
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.6", "", { "dependencies": { "@codemirror/language": "6.11.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.1", "@lezer/common": "1.2.3" } }, "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg=="],
|
||||||
|
|
||||||
@@ -147,7 +147,13 @@
|
|||||||
|
|
||||||
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
|
"@codemirror/view": ["@codemirror/view@6.38.1", "", { "dependencies": { "@codemirror/state": "6.5.2", "crelt": "1.0.6", "style-mod": "4.1.2", "w3c-keyname": "2.2.8" } }, "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ=="],
|
||||||
|
|
||||||
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.2.0", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA=="],
|
"@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.4.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "node-html-parser": "^7.0.2", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bDLdq0W2V1SkMQDJjrcYyjK9/uKtdl4joT7GRImcootCjZdKRiRYt+cv9z8tJoU/tK3o1lX48LTqN7JMsk5AQg=="],
|
||||||
|
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||||
|
|
||||||
@@ -293,12 +299,16 @@
|
|||||||
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||||
|
|
||||||
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "1.2.0" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
|
|
||||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||||
|
|
||||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||||
|
|
||||||
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
|
||||||
|
|
||||||
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
|
||||||
@@ -349,6 +359,38 @@
|
|||||||
|
|
||||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
"@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "2.0.2", "picomatch": "2.3.1" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA=="],
|
||||||
@@ -409,14 +451,14 @@
|
|||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "4.0.1", "debug": "4.4.1", "deepmerge": "4.3.1", "kleur": "4.1.5", "magic-string": "0.30.18", "vitefu": "1.1.1" }, "peerDependencies": { "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "5.1.1", "svelte": "5.38.6", "vite": "6.3.5" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
|
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.10", "", { "dependencies": { "mini-svg-data-uri": "1.4.4" }, "peerDependencies": { "tailwindcss": "3.4.17" } }, "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw=="],
|
||||||
|
|
||||||
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
|
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.5", "", {}, "sha512-48fAnUjKye38FvMiNOj0J9I/4XlQQiZlpe9xaNPfe8vy2Y1hFBt8g1yqf2EGjVvHavo4jf2lC+TQyENCr4BJBQ=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
|
"@types/argparse": ["@types/argparse@1.0.38", "", {}, "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="],
|
||||||
|
|
||||||
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
|
"@types/chai": ["@types/chai@4.3.20", "", {}, "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ=="],
|
||||||
@@ -527,7 +569,7 @@
|
|||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
|
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
|
||||||
|
|
||||||
@@ -631,7 +673,7 @@
|
|||||||
|
|
||||||
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
|
"colors-named-hex": ["colors-named-hex@1.0.2", "", {}, "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
|
"complex.js": ["complex.js@2.4.2", "", {}, "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g=="],
|
||||||
|
|
||||||
@@ -693,6 +735,8 @@
|
|||||||
|
|
||||||
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
"detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
|
||||||
|
|
||||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||||
|
|
||||||
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
|
||||||
@@ -773,7 +817,7 @@
|
|||||||
|
|
||||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||||
|
|
||||||
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
|
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
|
||||||
|
|
||||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "5.3.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
@@ -787,7 +831,7 @@
|
|||||||
|
|
||||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||||
|
|
||||||
"execa": ["execa@9.6.0", "", { "dependencies": { "@sindresorhus/merge-streams": "4.0.0", "cross-spawn": "7.0.6", "figures": "6.1.0", "get-stream": "9.0.1", "human-signals": "8.0.1", "is-plain-obj": "4.1.0", "is-stream": "4.0.1", "npm-run-path": "6.0.0", "pretty-ms": "9.2.0", "signal-exit": "4.1.0", "strip-final-newline": "4.0.0", "yoctocolors": "2.1.2" } }, "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw=="],
|
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||||
|
|
||||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
@@ -995,6 +1039,30 @@
|
|||||||
|
|
||||||
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
"lie": ["lie@3.1.1", "", { "dependencies": { "immediate": "3.0.6" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
@@ -1125,6 +1193,8 @@
|
|||||||
|
|
||||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||||
|
|
||||||
|
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||||
|
|
||||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||||
@@ -1143,6 +1213,8 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
@@ -1293,6 +1365,8 @@
|
|||||||
|
|
||||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||||
|
|
||||||
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
|
"rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="],
|
||||||
|
|
||||||
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "2.2.0", "xml2js": "0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
||||||
@@ -1397,7 +1471,7 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.38.6", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.5", "@types/estree": "1.0.8", "acorn": "8.15.0", "aria-query": "5.3.2", "axobject-query": "4.1.0", "clsx": "2.1.1", "esm-env": "1.2.2", "esrap": "2.1.0", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.18", "zimmerframe": "1.1.2" } }, "sha512-ltBPlkvqk3bgCK7/N323atUpP3O3Y+DrGV4dcULrsSn4fZaaNnOmdplNznwfdWclAgvSr5rxjtzn/zJhRm6TKg=="],
|
"svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
|
||||||
|
|
||||||
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
|
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
|
||||||
|
|
||||||
@@ -1423,7 +1497,7 @@
|
|||||||
|
|
||||||
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
|
"tinycolor2": ["tinycolor2@1.4.2", "", {}, "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
|
"tinypool": ["tinypool@0.7.0", "", {}, "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww=="],
|
||||||
|
|
||||||
@@ -1477,7 +1551,7 @@
|
|||||||
|
|
||||||
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
||||||
|
|
||||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|
||||||
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
|
"vite-node": ["vite-node@0.34.6", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "mlly": "1.8.0", "pathe": "1.1.2", "picocolors": "1.1.1", "vite": "5.4.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA=="],
|
||||||
|
|
||||||
@@ -1485,7 +1559,7 @@
|
|||||||
|
|
||||||
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
|
"vite-plugin-externalize-deps": ["vite-plugin-externalize-deps@0.7.0", "", { "peerDependencies": { "vite": "6.3.5" } }, "sha512-do2gPrR79Tm8UKcqsw3RTAtN4YO8GkVRBckWdJWINZ3Qdp3KN9S1oyUZxKszTB/iyg4zdOUweLOeBI8t86QVow=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "optionalDependencies": { "vite": "6.3.5" } }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|
||||||
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
|
"vitest": ["vitest@0.34.6", "", { "dependencies": { "@types/chai": "4.3.20", "@types/chai-subset": "1.3.6", "@types/node": "24.3.0", "@vitest/expect": "0.34.6", "@vitest/runner": "0.34.6", "@vitest/snapshot": "0.34.6", "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", "acorn": "8.15.0", "acorn-walk": "8.3.4", "cac": "6.7.14", "chai": "4.5.0", "debug": "4.4.1", "local-pkg": "0.4.3", "magic-string": "0.30.18", "pathe": "1.1.2", "picocolors": "1.1.1", "std-env": "3.9.0", "strip-literal": "1.3.0", "tinybench": "2.9.0", "tinypool": "0.7.0", "vite": "5.4.19", "vite-node": "0.34.6", "why-is-node-running": "2.3.0" }, "bin": { "vitest": "vitest.mjs" } }, "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q=="],
|
||||||
|
|
||||||
@@ -1555,7 +1629,9 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.0.2", "", { "dependencies": { "@rollup/pluginutils": "4.2.1", "@webcomponents/custom-elements": "1.6.0", "acorn-walk": "8.3.4", "cheerio": "1.1.2", "convert-source-map": "1.9.0", "debug": "4.4.1", "es-module-lexer": "0.10.5", "fast-glob": "3.3.3", "fs-extra": "10.1.0", "jsesc": "3.1.0", "magic-string": "0.30.18", "pathe": "2.0.3", "picocolors": "1.1.1", "react-refresh": "0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-BeaVEkCTmna2tzl5DL9nw1kxll1IpIFZ+wbl2+iILz4fNJy1xRD6c1nF8w8/CvrWUuPYTFTpyX9K+A30ISDXHA=="],
|
"@bedframe/cli/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
|
||||||
|
|
||||||
|
"@bedframe/core/@crxjs/vite-plugin": ["@crxjs/vite-plugin@2.3.0", "", { "dependencies": { "@rollup/pluginutils": "^4.1.2", "@webcomponents/custom-elements": "^1.5.0", "acorn-walk": "^8.2.0", "cheerio": "^1.0.0-rc.10", "convert-source-map": "^1.7.0", "debug": "^4.3.3", "es-module-lexer": "^0.10.0", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "jsesc": "^3.0.2", "magic-string": "^0.30.12", "pathe": "^2.0.1", "picocolors": "^1.1.1", "react-refresh": "^0.13.0", "rollup": "2.79.2", "rxjs": "7.5.7" } }, "sha512-+0CNVGS4bB30OoaF1vUsHVwWU1Lm7MxI0XWY9Fd/Ob+ZVTZgEFNqJ1ZC69IVwQsoYhY0sMQLvpLWiFIuDz8htg=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
@@ -1591,6 +1667,8 @@
|
|||||||
|
|
||||||
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
"@samverschueren/stream-to-observable/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"@vitest/runner/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
@@ -1621,6 +1699,8 @@
|
|||||||
|
|
||||||
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "8.0.1", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
|
"dependency-cruiser/commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="],
|
||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"eslint/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=="],
|
"eslint/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=="],
|
||||||
@@ -1633,6 +1713,8 @@
|
|||||||
|
|
||||||
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||||
|
|
||||||
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
|
"listr/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="],
|
||||||
|
|
||||||
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
"listr/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "1.14.1" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
|
||||||
@@ -1715,9 +1797,13 @@
|
|||||||
|
|
||||||
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
"tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "3.1.3", "braces": "3.0.3", "glob-parent": "5.1.2", "is-binary-path": "2.1.0", "is-glob": "4.0.3", "normalize-path": "3.0.0", "readdirp": "3.6.0" }, "optionalDependencies": { "fsevents": "2.3.3" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
|
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||||
|
|
||||||
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"vite-node/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
@@ -1725,6 +1811,8 @@
|
|||||||
|
|
||||||
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
|
"vite-plugin-dts/@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "1.0.8", "estree-walker": "2.0.2", "picomatch": "4.0.3" }, "optionalDependencies": { "rollup": "4.49.0" } }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "0.25.9", "fdir": "6.5.0", "picomatch": "4.0.3", "postcss": "8.5.6", "rollup": "4.49.0", "tinyglobby": "0.2.14" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "jiti": "1.21.7", "sass": "1.91.0", "yaml": "2.8.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||||
|
|
||||||
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
"vitest/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||||
|
|
||||||
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
|
"vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.6", "rollup": "4.49.0" }, "optionalDependencies": { "@types/node": "24.3.0", "fsevents": "2.3.3", "sass": "1.91.0" }, "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
|
||||||
@@ -1741,6 +1829,14 @@
|
|||||||
|
|
||||||
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
"z-schema/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||||
|
|
||||||
|
"@bedframe/cli/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"@bedframe/core/@crxjs/vite-plugin/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
@@ -1865,6 +1961,10 @@
|
|||||||
|
|
||||||
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vite-plugin-dts/@rollup/pluginutils/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|
||||||
|
"vite-plugin-dts/vite/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||||
|
|
||||||
"vitest/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=="],
|
"vitest/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=="],
|
||||||
|
|
||||||
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
"vitest/vite/rollup": ["rollup@4.49.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.49.0", "@rollup/rollup-android-arm64": "4.49.0", "@rollup/rollup-darwin-arm64": "4.49.0", "@rollup/rollup-darwin-x64": "4.49.0", "@rollup/rollup-freebsd-arm64": "4.49.0", "@rollup/rollup-freebsd-x64": "4.49.0", "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", "@rollup/rollup-linux-arm-musleabihf": "4.49.0", "@rollup/rollup-linux-arm64-gnu": "4.49.0", "@rollup/rollup-linux-arm64-musl": "4.49.0", "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", "@rollup/rollup-linux-ppc64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-gnu": "4.49.0", "@rollup/rollup-linux-riscv64-musl": "4.49.0", "@rollup/rollup-linux-s390x-gnu": "4.49.0", "@rollup/rollup-linux-x64-gnu": "4.49.0", "@rollup/rollup-linux-x64-musl": "4.49.0", "@rollup/rollup-win32-arm64-msvc": "4.49.0", "@rollup/rollup-win32-ia32-msvc": "4.49.0", "@rollup/rollup-win32-x64-msvc": "4.49.0", "fsevents": "2.3.3" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA=="],
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ Upserts the caller’s settings backup.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
|
"themeId": "uuid-string-or-empty",
|
||||||
"data": {
|
"data": {
|
||||||
"...": "flat key-value map mirroring extension storage (see Payload shape)"
|
"...": "flat key-value map mirroring extension storage (see Payload shape)",
|
||||||
|
"selectedTheme": "uuid-or-empty-string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
|
- **`schemaVersion`**: integer. The extension currently sends `1`. The server may reject unknown major versions or store it for future migrations.
|
||||||
|
- **`themeId`**: optional but recommended duplicate of **`data.selectedTheme`**. Should be the UUID of the **installed** BetterSEQTA store theme (`selectedTheme`). This may be a normal theme id **or** a **flavour (slave) variant** id from themes with **`flavours[]`** — the extension uses it after restore to prefetch `theme.json` when missing locally (same **`GET …/themes/{id}/download`** as the store UI). Persist and return **`themeId`** in sync with **`data.selectedTheme`**.
|
||||||
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
|
- **`data`**: object whose keys are storage keys (strings) and values are JSON-serializable values (same types as stored in `chrome.storage.local`).
|
||||||
|
|
||||||
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
|
**Success response:** HTTP `200` (or `201` if you prefer create semantics). Example:
|
||||||
@@ -67,15 +70,19 @@ Returns the caller’s latest settings backup.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
|
"themeId": "uuid-string-or-empty",
|
||||||
"data": { },
|
"data": { },
|
||||||
"updated_at": "2026-04-07T12:00:00.000Z"
|
"updated_at": "2026-04-07T12:00:00.000Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
|
- **`data`**: required for restore; must be the same shape as accepted in `PUT` (flat map of storage keys).
|
||||||
|
- **`themeId`**: optional; if present must match **`data.selectedTheme`** (see `PUT`).
|
||||||
- **`schemaVersion`**: optional but recommended; should match what was stored.
|
- **`schemaVersion`**: optional but recommended; should match what was stored.
|
||||||
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
|
- **`updated_at`**: optional; included for UX if the client shows “last backup” time.
|
||||||
|
|
||||||
|
The extension resolves **`themeId`** (if non-empty), else **`data.selectedTheme`,** to [`resolveThemeIdForPostSyncDownload`](../src/seqta/utils/cloudSettingsSync.ts) after downloading the envelope — used only to reinstall theme assets from **`betterseqta.org`** when IndexedDB lacks that id (see **BetterSEQTA Cloud** flavour note in **[THEME_STORE_FLAVOURS_API](./THEME_STORE_FLAVOURS_API.md)** section “Cloud settings sync compatibility”).
|
||||||
|
|
||||||
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
|
**No backup yet:** HTTP **`404`**. The extension treats this as “nothing in the cloud” and shows an error to the user.
|
||||||
|
|
||||||
**Error responses:** `401` if the token is invalid, etc.
|
**Error responses:** `401` if the token is invalid, etc.
|
||||||
@@ -128,6 +135,6 @@ This uses standard **WebExtension** APIs (`browser.alarms`, `runtime` messages,
|
|||||||
|
|
||||||
## Client reference (extension)
|
## Client reference (extension)
|
||||||
|
|
||||||
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, and **`bsplus_cloud_settings_known_remote_updated_at`**).
|
- Upload / dev export: `buildUploadPayload` / `getSnapshotForUpload` in `src/seqta/utils/cloudSettingsSync.ts` (strips OAuth-related keys, sensitive device keys, **`bsplus_pending_theme_ensure_after_cloud`**, and **`bsplus_cloud_settings_known_remote_updated_at`** — includes **`themeId`** aligned with **`selectedTheme`**).
|
||||||
- Download: `applyDownloadedEnvelope` after `GET`; local auth keys, sensitive device keys, and the client-only watermark key are merged back after `chrome.storage.local.clear()`.
|
- Download: resolve id via **`resolveThemeIdForPostSyncDownload`** → **`applyDownloadedEnvelope`** after `GET` → prefetch theme blobs in page context if needed (**`prepareThemeAfterCloudSync`** in **`ThemeManager`**) → reload SEQTA tabs; local auth keys, sensitive device keys, client-only watermark, and **`bsplus_pending_theme_ensure_after_cloud`** semantics preserved as documented above.
|
||||||
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
|
- Auto sync (summary, debounced upload, alarms): `src/background/cloudSettingsAutoSync.ts`; content script triggers a poll on each verified SEQTA Learn/Engage page load (top frame) via `cloudSettingsPoll`.
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "betterseqtaplus",
|
"name": "betterseqtaplus",
|
||||||
"version": "3.6.3",
|
"version": "3.6.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
|
||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
"dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
|
||||||
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
"release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes",
|
||||||
"publish": "bun lib/publish.js --b",
|
"publish": "bun lib/publish.js --b",
|
||||||
"zip": "bedframe zip"
|
"zip": "bedframe zip",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"prod": {
|
"prod": {
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@babel/runtime": "^7.26.9",
|
"@babel/runtime": "^7.26.9",
|
||||||
"@bedframe/cli": "^0.1.2",
|
"@bedframe/cli": "^0.1.2",
|
||||||
"@crxjs/vite-plugin": "^2.4.0",
|
"@crxjs/vite-plugin": "^2.4.0",
|
||||||
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"dependency-cruiser": "^17.0.1",
|
"dependency-cruiser": "^17.0.1",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
@@ -55,7 +58,8 @@
|
|||||||
"sass-loader": "^16.0.5",
|
"sass-loader": "^16.0.5",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"tailwindcss": "3",
|
"tailwindcss": "3",
|
||||||
"url": "^0.11.4"
|
"url": "^0.11.4",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bedframe/core": "^0.1.0",
|
"@bedframe/core": "^0.1.0",
|
||||||
@@ -92,6 +96,7 @@
|
|||||||
"flexsearch": "^0.8.147",
|
"flexsearch": "^0.8.147",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"idb": "^8.0.2",
|
"idb": "^8.0.2",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mathjs": "^14.4.0",
|
"mathjs": "^14.4.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
initCloudSettingsAutoSync,
|
initCloudSettingsAutoSync,
|
||||||
performCloudSettingsDownloadWithRetry,
|
performCloudSettingsDownloadWithRetry,
|
||||||
performCloudSettingsUploadWithRetry,
|
performCloudSettingsUploadWithRetry,
|
||||||
|
requestCloudSettingsDebouncedUpload,
|
||||||
runCloudSettingsPoll,
|
runCloudSettingsPoll,
|
||||||
} from "./background/cloudSettingsAutoSync";
|
} from "./background/cloudSettingsAutoSync";
|
||||||
|
|
||||||
@@ -346,6 +347,10 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
|
|||||||
void runCloudSettingsPoll();
|
void runCloudSettingsPoll();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
cloudSettingsRequestDebouncedUpload: () => {
|
||||||
|
requestCloudSettingsDebouncedUpload();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
getSeqtaSession: (req: { baseUrl?: string }, sendResponse: MessageSender, sender?: browser.Runtime.MessageSender) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
applyDownloadedEnvelope,
|
applyDownloadedEnvelope,
|
||||||
buildUploadPayload,
|
buildUploadPayload,
|
||||||
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
||||||
|
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
||||||
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||||
isKeyIncludedInCloudUploadPayload,
|
isKeyIncludedInCloudUploadPayload,
|
||||||
|
resolveThemeIdForPostSyncDownload,
|
||||||
setKnownRemoteUpdatedAt,
|
setKnownRemoteUpdatedAt,
|
||||||
} from "@/seqta/utils/cloudSettingsSync";
|
} from "@/seqta/utils/cloudSettingsSync";
|
||||||
|
|
||||||
@@ -220,7 +222,15 @@ async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
|
|||||||
error: data?.error ?? `Download failed (${r.status})`,
|
error: data?.error ?? `Download failed (${r.status})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const themeIdToEnsure = resolveThemeIdForPostSyncDownload(data);
|
||||||
await applyDownloadedEnvelope(data);
|
await applyDownloadedEnvelope(data);
|
||||||
|
if (themeIdToEnsure) {
|
||||||
|
await browser.storage.local.set({
|
||||||
|
[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY]: themeIdToEnsure,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
|
||||||
|
}
|
||||||
reloadSeqtaPagesFn?.();
|
reloadSeqtaPagesFn?.();
|
||||||
const updated_at = data?.updated_at as string | undefined;
|
const updated_at = data?.updated_at as string | undefined;
|
||||||
await setKnownRemoteUpdatedAt(updated_at);
|
await setKnownRemoteUpdatedAt(updated_at);
|
||||||
@@ -352,6 +362,17 @@ function scheduleDebouncedUpload(): void {
|
|||||||
}, UPLOAD_DEBOUNCE_MS);
|
}, UPLOAD_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Call after store theme install (and similar) so cloud upload runs even if storage events are flaky. */
|
||||||
|
export function requestCloudSettingsDebouncedUpload(): void {
|
||||||
|
void (async () => {
|
||||||
|
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||||
|
if (!isAutoCloudSyncEnabled(all)) return;
|
||||||
|
if (suppressAutoUploadDuringRestore) return;
|
||||||
|
if (!(await getAccessToken())) return;
|
||||||
|
scheduleDebouncedUpload();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
async function runDebouncedUploadJob(): Promise<void> {
|
async function runDebouncedUploadJob(): Promise<void> {
|
||||||
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
const all = (await browser.storage.local.get()) as Record<string, unknown>;
|
||||||
if (!isAutoCloudSyncEnabled(all)) return;
|
if (!isAutoCloudSyncEnabled(all)) return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
@use "sass:meta";
|
@use "sass:meta";
|
||||||
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
|
@import url("https://fonts.googleapis.com/css?family=Rubik:300,400,500,600,700");
|
||||||
|
|
||||||
@@ -2609,7 +2610,7 @@ body {
|
|||||||
|
|
||||||
[class*="MessageList__unread___"] {
|
[class*="MessageList__unread___"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: rgb(228 225 225);
|
background: var(--background-secondary, rgb(228 225 225));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark [class*="MessageList__unread___"] {
|
.dark [class*="MessageList__unread___"] {
|
||||||
@@ -2735,7 +2736,7 @@ body {
|
|||||||
[class*="MessageList__MessageList___"]
|
[class*="MessageList__MessageList___"]
|
||||||
> ol
|
> ol
|
||||||
> li[class*="MessageList__selected___"] {
|
> li[class*="MessageList__selected___"] {
|
||||||
background: rgb(228 225 225);
|
background: var(--background-secondary, rgb(228 225 225));
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { Theme } from '@/interface/types/Theme';
|
import type { ThemeCoverSlide } from '@/interface/types/Theme';
|
||||||
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||||
import Autoplay from 'embla-carousel-autoplay';
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
|
|
||||||
let { coverThemes, setDisplayTheme } = $props<{ coverThemes: Theme[], setDisplayTheme: (theme: Theme) => void }>();
|
let { slides, setDisplayTheme } = $props<{
|
||||||
|
slides: ThemeCoverSlide[];
|
||||||
|
setDisplayTheme: (theme: import('@/interface/types/Theme').Theme) => void;
|
||||||
|
}>();
|
||||||
let emblaApi = $state();
|
let emblaApi = $state();
|
||||||
|
|
||||||
const options = { loop: true };
|
const options = { loop: true };
|
||||||
@@ -12,8 +15,8 @@
|
|||||||
Autoplay({
|
Autoplay({
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
stopOnInteraction: false,
|
stopOnInteraction: false,
|
||||||
stopOnMouseEnter: true
|
stopOnMouseEnter: true,
|
||||||
})
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
function onInit(event: CustomEvent) {
|
function onInit(event: CustomEvent) {
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
const slideNext = () => emblaApi?.scrollNext();
|
const slideNext = () => emblaApi?.scrollNext();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if coverThemes.length > 0}
|
{#if slides.length > 0}
|
||||||
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
<div class="relative w-full overflow-clip rounded-xl transition-opacity" transition:fade>
|
||||||
<div
|
<div
|
||||||
class="w-full aspect-[5/1] max-h-[500px]"
|
class="w-full aspect-[5/1] max-h-[500px]"
|
||||||
@@ -34,49 +37,69 @@
|
|||||||
onemblaInit={onInit}
|
onemblaInit={onInit}
|
||||||
>
|
>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
{#each coverThemes as theme}
|
{#each slides as slide (slide.imageUrl + slide.title + (slide.subtitle ?? ''))}
|
||||||
<div
|
<div
|
||||||
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
class="relative flex-[0_0_100%] cursor-pointer rounded-xl overflow-clip"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
onkeydown={(e) => {
|
||||||
onclick={() => setDisplayTheme(theme)}
|
if (e.key === 'Enter') setDisplayTheme(slide.openTheme);
|
||||||
|
}}
|
||||||
|
onclick={() => setDisplayTheme(slide.openTheme)}
|
||||||
>
|
>
|
||||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
<img src={slide.imageUrl} alt="" class="object-cover w-full h-full" />
|
||||||
{#if theme.featured === true}
|
{#if slide.badgeFeatured === true}
|
||||||
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
|
<div class="absolute top-4 left-4 z-[2] pointer-events-none">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100 shadow-sm"
|
||||||
aria-label="Featured theme"
|
aria-label="Featured theme"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
|
||||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Featured
|
Featured
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
<div class="absolute bottom-0 left-0 p-8 z-[1]">
|
||||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
<h2 class="text-4xl font-bold text-white">{slide.title}</h2>
|
||||||
{#if theme.author}
|
{#if slide.subtitle}
|
||||||
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {theme.author}</p>
|
<p class="text-lg font-medium text-white/95 mt-1 line-clamp-2">{slide.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
{#if slide.openTheme.author && !slide.subtitle}
|
||||||
|
<p class="text-sm text-white/90 mt-1 mb-1 line-clamp-1">By {slide.openTheme.author}</p>
|
||||||
|
{/if}
|
||||||
|
{#if slide.openTheme.description && !slide.subtitle}
|
||||||
|
<p class="text-lg text-white line-clamp-3">{slide.openTheme.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class='text-lg text-white'>{theme.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80'></div>
|
<div class="absolute bottom-0 left-0 w-full h-1/2 to-transparent bg-linear-to-t from-black/80"></div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation buttons -->
|
<div class="flex absolute right-2 bottom-2 z-10 gap-2">
|
||||||
<div class='flex absolute right-2 bottom-2 z-10 gap-2'>
|
<button
|
||||||
<button aria-label="Previous" onclick={slidePrev} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
|
aria-label="Previous"
|
||||||
|
onclick={slidePrev}
|
||||||
|
type="button"
|
||||||
|
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Next" onclick={slideNext} class='flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800'>
|
<button
|
||||||
|
aria-label="Next"
|
||||||
|
onclick={slideNext}
|
||||||
|
type="button"
|
||||||
|
class="flex justify-center items-center w-8 h-8 text-white rounded-full bg-black/50 dark:bg-zinc-800 transition-all duration-200"
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-6 h-6">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
|
import {
|
||||||
|
masterGridDisplayDownloadCount,
|
||||||
|
gridCardPreviewImageUrls,
|
||||||
|
} from '@/interface/utils/themeStoreFlavours'
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
|
import emblaCarouselSvelte from 'embla-carousel-svelte';
|
||||||
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
|
let { theme, onClick, toggleFavorite, isLoggedIn, onRequestSignIn, allStoreThemeRows } = $props<{
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
toggleFavorite: (theme: Theme) => void;
|
toggleFavorite: (theme: Theme) => void;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
onRequestSignIn?: () => void;
|
onRequestSignIn?: () => void;
|
||||||
|
/** Raw API themes (includes hidden slaves) for aggregated master download totals */
|
||||||
|
allStoreThemeRows?: Theme[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const displayDownloadCount = $derived(
|
||||||
|
allStoreThemeRows != null
|
||||||
|
? masterGridDisplayDownloadCount(theme, allStoreThemeRows)
|
||||||
|
: (theme.download_count ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridRotatorUrls = $derived(gridCardPreviewImageUrls(theme, allStoreThemeRows));
|
||||||
|
|
||||||
|
/** Mirrors CoverSwiper (featured bar): horizontal slides + autoplay */
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read once synchronously where `window` exists so reduced-motion doesn’t briefly mount carousel */
|
||||||
|
let allowSlideAutoplay = $state(!prefersReducedMotion());
|
||||||
|
|
||||||
|
const gridEmblaKey = $derived(gridRotatorUrls.join('|'));
|
||||||
|
|
||||||
|
const gridEmblaOptions = $derived({ loop: gridRotatorUrls.length > 1 });
|
||||||
|
|
||||||
|
const gridEmblaPlugins = $derived.by(() => {
|
||||||
|
if (!allowSlideAutoplay || gridRotatorUrls.length <= 1) return [];
|
||||||
|
return [
|
||||||
|
Autoplay({
|
||||||
|
delay: 2000,
|
||||||
|
stopOnInteraction: false,
|
||||||
|
stopOnMouseEnter: true,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let menuRef: HTMLDivElement;
|
let menuRef: HTMLDivElement;
|
||||||
|
|
||||||
@@ -111,7 +151,7 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
</svg>
|
</svg>
|
||||||
{(theme.download_count ?? 0).toLocaleString()}
|
{displayDownloadCount.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-3.5 h-3.5">
|
||||||
@@ -122,8 +162,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||||
<div class='w-full'>
|
{#if gridRotatorUrls.length === 0}
|
||||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
<div class="relative w-full h-48 overflow-hidden rounded-md bg-zinc-200 dark:bg-zinc-700" aria-hidden="true"></div>
|
||||||
</div>
|
{:else if !allowSlideAutoplay || gridRotatorUrls.length === 1}
|
||||||
|
<div class="relative w-full h-48 overflow-hidden rounded-md">
|
||||||
|
<img
|
||||||
|
src={gridRotatorUrls[0] ?? theme.marqueeImage ?? theme.coverImage}
|
||||||
|
alt=""
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#key gridEmblaKey}
|
||||||
|
<div
|
||||||
|
class="relative w-full h-48 overflow-hidden rounded-md"
|
||||||
|
use:emblaCarouselSvelte={{
|
||||||
|
options: gridEmblaOptions,
|
||||||
|
plugins: gridEmblaPlugins,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex h-full">
|
||||||
|
{#each gridRotatorUrls as url (url)}
|
||||||
|
<div class="relative flex-[0_0_100%] min-w-0 h-full shrink-0">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
class="object-cover w-full h-full select-none"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,23 @@
|
|||||||
import type { Theme } from '@/interface/types/Theme'
|
import type { Theme } from '@/interface/types/Theme'
|
||||||
import ThemeCard from './ThemeCard.svelte';
|
import ThemeCard from './ThemeCard.svelte';
|
||||||
|
|
||||||
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn, onRequestSignIn } = $props<{
|
let {
|
||||||
|
themes,
|
||||||
|
searchTerm,
|
||||||
|
setDisplayTheme,
|
||||||
|
toggleFavorite,
|
||||||
|
isLoggedIn,
|
||||||
|
onRequestSignIn,
|
||||||
|
allStoreThemeRows,
|
||||||
|
} = $props<{
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
setDisplayTheme: (theme: Theme) => void;
|
setDisplayTheme: (theme: Theme) => void;
|
||||||
toggleFavorite: (theme: Theme) => void;
|
toggleFavorite: (theme: Theme) => void;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
onRequestSignIn?: () => void;
|
onRequestSignIn?: () => void;
|
||||||
|
/** Raw API list (includes `slave` rows) for master download aggregation */
|
||||||
|
allStoreThemeRows?: Theme[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||||
@@ -25,6 +35,7 @@
|
|||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
{isLoggedIn}
|
{isLoggedIn}
|
||||||
{onRequestSignIn}
|
{onRequestSignIn}
|
||||||
|
{allStoreThemeRows}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
import SkeletonLoader from '../components/SkeletonLoader.svelte';
|
||||||
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
import { settingsState } from '@/seqta/utils/listeners/SettingsState'
|
||||||
import type { Theme } from '../types/Theme'
|
import type { Theme } from '../types/Theme'
|
||||||
|
import { visibleStoreThemes, buildCoverSlidesForThemes } from '@/interface/utils/themeStoreFlavours'
|
||||||
import browser from 'webextension-polyfill'
|
import browser from 'webextension-polyfill'
|
||||||
import ThemeModal from '../components/store/ThemeModal.svelte'
|
import ThemeModal from '../components/store/ThemeModal.svelte'
|
||||||
import Header from '../components/store/Header.svelte'
|
import Header from '../components/store/Header.svelte'
|
||||||
@@ -26,7 +27,12 @@
|
|||||||
// State variables
|
// State variables
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let themes = $state<Theme[]>([]);
|
let themes = $state<Theme[]>([]);
|
||||||
let coverThemes = $state<Theme[]>([]);
|
|
||||||
|
/** Grid/search/cover: hides flat-listed slaves when API sends them */
|
||||||
|
let listThemes = $derived(visibleStoreThemes(themes));
|
||||||
|
|
||||||
|
/** Cover marquee slides (master + flavour imagery for top masters) */
|
||||||
|
let coverSlides = $derived(buildCoverSlidesForThemes(listThemes.slice(0, 3)));
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let darkMode = $state(false);
|
let darkMode = $state(false);
|
||||||
let displayTheme = $state<Theme | null>(null);
|
let displayTheme = $state<Theme | null>(null);
|
||||||
@@ -108,7 +114,6 @@
|
|||||||
throw new Error(data?.error || 'Failed to fetch themes');
|
throw new Error(data?.error || 'Failed to fetch themes');
|
||||||
}
|
}
|
||||||
themes = [...data.data.themes].sort(compareStoreThemes);
|
themes = [...data.data.themes].sort(compareStoreThemes);
|
||||||
coverThemes = themes.slice(0, 3);
|
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,13 +133,36 @@
|
|||||||
|
|
||||||
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
// Filter themes (list is already featured-first, then newest; filter preserves order)
|
||||||
let filteredThemes = $derived(
|
let filteredThemes = $derived(
|
||||||
themes.filter(
|
listThemes.filter(
|
||||||
(theme) =>
|
(theme) =>
|
||||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
|
theme.description.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function installThemeFromStore(themeId: string, meta: Theme) {
|
||||||
|
const fullRow = themes.find((x) => x.id === themeId);
|
||||||
|
if (fullRow) {
|
||||||
|
await themeManager.downloadTheme(fullRow);
|
||||||
|
} else {
|
||||||
|
const flavour = meta.flavours?.find((f) => f.id === themeId);
|
||||||
|
await themeManager.downloadTheme({
|
||||||
|
id: themeId,
|
||||||
|
name: flavour?.name ?? meta.name,
|
||||||
|
} as Theme);
|
||||||
|
}
|
||||||
|
await themeManager.setTheme(themeId);
|
||||||
|
themeUpdates.triggerUpdate();
|
||||||
|
await fetchCurrentThemes();
|
||||||
|
void browser.runtime.sendMessage({ type: 'cloudSettingsRequestDebouncedUpload' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeThemeFromStore(themeId: string) {
|
||||||
|
await themeManager.deleteTheme(themeId);
|
||||||
|
themeUpdates.triggerUpdate();
|
||||||
|
await fetchCurrentThemes();
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadBackground();
|
loadBackground();
|
||||||
selectedBackground
|
selectedBackground
|
||||||
@@ -172,12 +200,13 @@
|
|||||||
<!-- Themes Tab Content -->
|
<!-- Themes Tab Content -->
|
||||||
{#if activeTab === 'themes'}
|
{#if activeTab === 'themes'}
|
||||||
{#if searchTerm === ''}
|
{#if searchTerm === ''}
|
||||||
<CoverSwiper {coverThemes} {setDisplayTheme} />
|
<CoverSwiper slides={coverSlides} {setDisplayTheme} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ThemeGrid to display filtered themes -->
|
<!-- ThemeGrid to display filtered themes -->
|
||||||
<ThemeGrid
|
<ThemeGrid
|
||||||
themes={filteredThemes}
|
themes={filteredThemes}
|
||||||
|
allStoreThemeRows={themes}
|
||||||
{searchTerm}
|
{searchTerm}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
@@ -188,28 +217,20 @@
|
|||||||
{#if displayTheme}
|
{#if displayTheme}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
currentThemes={currentThemes}
|
currentThemes={currentThemes}
|
||||||
allThemes={themes}
|
allThemes={listThemes}
|
||||||
|
allStoreThemeRows={themes}
|
||||||
theme={displayTheme}
|
theme={displayTheme}
|
||||||
{displayTheme}
|
{displayTheme}
|
||||||
{setDisplayTheme}
|
{setDisplayTheme}
|
||||||
{toggleFavorite}
|
{toggleFavorite}
|
||||||
isLoggedIn={cloudLoggedIn}
|
isLoggedIn={cloudLoggedIn}
|
||||||
onRequestSignIn={() => (showSignInOverlay = true)}
|
onRequestSignIn={() => (showSignInOverlay = true)}
|
||||||
onInstall={async () => {
|
onInstall={async (themeId: string) => {
|
||||||
if (displayTheme) {
|
if (displayTheme) await installThemeFromStore(themeId, displayTheme);
|
||||||
await themeManager.downloadTheme(displayTheme);
|
|
||||||
await themeManager.setTheme(displayTheme.id);
|
|
||||||
themeUpdates.triggerUpdate();
|
|
||||||
await fetchCurrentThemes();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onRemove={async () => {
|
onRemove={async (themeId: string) => {
|
||||||
if (displayTheme?.id) {
|
console.debug('deleting theme', themeId);
|
||||||
console.debug('deleting theme', displayTheme.id);
|
await removeThemeFromStore(themeId);
|
||||||
await themeManager.deleteTheme(displayTheme.id);
|
|
||||||
themeUpdates.triggerUpdate();
|
|
||||||
await fetchCurrentThemes();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
export type ThemeRole = "standard" | "master" | "slave";
|
||||||
|
|
||||||
|
/** List/detail metadata for variants of a master theme (full theme.json fetched at install by id). */
|
||||||
|
export type ThemeFlavour = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Mirrors theme.json accent (e.g. defaultColour); used for install picker buttons */
|
||||||
|
accent_color: string;
|
||||||
|
cover_image: string;
|
||||||
|
marquee_image?: string;
|
||||||
|
/** Per-variant installs when slaves are not returned as flat `theme_role` rows */
|
||||||
|
download_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,4 +29,22 @@ export type Theme = {
|
|||||||
created_at?: number;
|
created_at?: number;
|
||||||
/** Unix seconds — last server update (GET /api/themes). */
|
/** Unix seconds — last server update (GET /api/themes). */
|
||||||
updated_at?: number;
|
updated_at?: number;
|
||||||
|
/** Omitted / `standard` — show in grid. `slave` hides from grid. `master` can list `flavours`. */
|
||||||
|
theme_role?: ThemeRole;
|
||||||
|
/** Present when `theme_role === "slave"` and API returns a flat list during migration */
|
||||||
|
master_id?: string;
|
||||||
|
/** Variants nested on master rows; installs use flavour `id` */
|
||||||
|
flavours?: ThemeFlavour[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One marquee slide (cover hero or modal carousel). */
|
||||||
|
export type ThemeCoverSlide = {
|
||||||
|
imageUrl: string;
|
||||||
|
/** Main line — usually master name */
|
||||||
|
title: string;
|
||||||
|
/** Subline — flavour name when applicable */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Opening the modal uses this theme (always the grid row / master object) */
|
||||||
|
openTheme: Theme;
|
||||||
|
badgeFeatured?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { Theme, ThemeCoverSlide, ThemeFlavour } from "@/interface/types/Theme";
|
||||||
|
|
||||||
|
export function isHiddenStoreTheme(theme: Theme): boolean {
|
||||||
|
return theme.theme_role === "slave";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid and search: omit slave rows (when API sends a flattened list). */
|
||||||
|
export function visibleStoreThemes(themes: Theme[]): Theme[] {
|
||||||
|
return themes.filter((t) => !isHiddenStoreTheme(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
function marqueeOrCoverUrl(t: { marqueeImage?: string; coverImage: string }): string {
|
||||||
|
return t.marqueeImage || t.coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds slides for CoverSwiper: for each top-N master, first master image then each flavour image.
|
||||||
|
*/
|
||||||
|
export function buildCoverSlidesForThemes(topThemes: Theme[]): ThemeCoverSlide[] {
|
||||||
|
const slides: ThemeCoverSlide[] = [];
|
||||||
|
for (const theme of topThemes) {
|
||||||
|
const flavours = theme.flavours ?? [];
|
||||||
|
if (flavours.length === 0) {
|
||||||
|
slides.push({
|
||||||
|
imageUrl: marqueeOrCoverUrl(theme),
|
||||||
|
title: theme.name,
|
||||||
|
subtitle: theme.author ? `By ${theme.author}` : undefined,
|
||||||
|
openTheme: theme,
|
||||||
|
badgeFeatured: theme.featured === true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
slides.push({
|
||||||
|
imageUrl: marqueeOrCoverUrl(theme),
|
||||||
|
title: theme.name,
|
||||||
|
subtitle: theme.author ? `By ${theme.author}` : undefined,
|
||||||
|
openTheme: theme,
|
||||||
|
badgeFeatured: theme.featured === true,
|
||||||
|
});
|
||||||
|
for (const f of flavours) {
|
||||||
|
slides.push({
|
||||||
|
imageUrl: f.marquee_image || f.cover_image,
|
||||||
|
title: theme.name,
|
||||||
|
subtitle: f.name,
|
||||||
|
openTheme: theme,
|
||||||
|
badgeFeatured: theme.featured === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slides;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModalHeroSlide = { imageUrl: string; caption: string };
|
||||||
|
|
||||||
|
/** Preview image for carousel + flavour picker (matches hero slide order after master slide). */
|
||||||
|
export function flavourCarouselImageUrl(f: ThemeFlavour): string {
|
||||||
|
const u = (f.marquee_image || f.cover_image || "").trim();
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview image for master variant tile (modal hero slide 0). */
|
||||||
|
export function masterCarouselImageUrl(t: Theme): string {
|
||||||
|
return (marqueeOrCoverUrl(t) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordered preview URLs for the store grid card rotator: master first, then each variant.
|
||||||
|
* Uses nested `flavours` when present; otherwise flat `slave` rows (same order as modal when possible).
|
||||||
|
*/
|
||||||
|
export function gridCardPreviewImageUrls(theme: Theme, allStoreRows?: Theme[]): string[] {
|
||||||
|
const urls: string[] = [];
|
||||||
|
const push = (raw: string) => {
|
||||||
|
const u = raw.trim();
|
||||||
|
if (u && !urls.includes(u)) urls.push(u);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(marqueeOrCoverUrl(theme) || "");
|
||||||
|
|
||||||
|
const flavours = theme.flavours ?? [];
|
||||||
|
if (flavours.length > 0) {
|
||||||
|
for (const f of flavours) {
|
||||||
|
push(flavourCarouselImageUrl(f));
|
||||||
|
}
|
||||||
|
return urls.length > 0 ? urls : [(theme.coverImage || "").trim()].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allStoreRows) {
|
||||||
|
const slaves = allStoreRows
|
||||||
|
.filter((t) => t.theme_role === "slave" && t.master_id === theme.id)
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
|
for (const s of slaves) {
|
||||||
|
push((marqueeOrCoverUrl(s) || "").trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length > 0) return urls;
|
||||||
|
const fallback = (theme.coverImage || "").trim();
|
||||||
|
return fallback ? [fallback] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads shown on the grid card for a master row: master's count plus each slave
|
||||||
|
* (flat `theme_role === "slave"` with `master_id`) and any flavour-only `download_count`
|
||||||
|
* when there is no matching flat slave id (nested-only API shape).
|
||||||
|
*/
|
||||||
|
export function masterGridDisplayDownloadCount(master: Theme, allStoreRows: Theme[]): number {
|
||||||
|
let total = master.download_count ?? 0;
|
||||||
|
const slaveRows = allStoreRows.filter(
|
||||||
|
(t) => t.theme_role === "slave" && t.master_id === master.id,
|
||||||
|
);
|
||||||
|
const countedIds = new Set(slaveRows.map((r) => r.id));
|
||||||
|
for (const r of slaveRows) {
|
||||||
|
total += r.download_count ?? 0;
|
||||||
|
}
|
||||||
|
for (const f of master.flavours ?? []) {
|
||||||
|
if (countedIds.has(f.id)) continue;
|
||||||
|
total += f.download_count ?? 0;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal hero: master first, then each flavour (plan order). */
|
||||||
|
export function buildModalHeroSlides(theme: Theme): ModalHeroSlide[] {
|
||||||
|
const slides: ModalHeroSlide[] = [];
|
||||||
|
slides.push({
|
||||||
|
imageUrl: marqueeOrCoverUrl(theme),
|
||||||
|
caption: theme.name,
|
||||||
|
});
|
||||||
|
const flavours = theme.flavours ?? [];
|
||||||
|
for (const f of flavours) {
|
||||||
|
slides.push({
|
||||||
|
imageUrl: f.marquee_image || f.cover_image,
|
||||||
|
caption: `${theme.name} — ${f.name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return slides;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative luminance 0–1 for rgba/rgb/hex-ish strings; fallback 0.5 → white text
|
||||||
|
*/
|
||||||
|
export function pickContrastingTextColor(backgroundCss: string): "#ffffff" | "#0a0a0a" {
|
||||||
|
const s = backgroundCss.trim();
|
||||||
|
const rgba = s.match(
|
||||||
|
/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/i,
|
||||||
|
);
|
||||||
|
if (rgba) {
|
||||||
|
const r = Number(rgba[1]) / 255;
|
||||||
|
const g = Number(rgba[2]) / 255;
|
||||||
|
const b = Number(rgba[3]) / 255;
|
||||||
|
const a = rgba[4] !== undefined ? Number(rgba[4]) : 1;
|
||||||
|
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
const effective = lum * a + 0.05 * (1 - a);
|
||||||
|
return effective > 0.45 ? "#0a0a0a" : "#ffffff";
|
||||||
|
}
|
||||||
|
const hex = s.match(/^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
|
||||||
|
if (hex) {
|
||||||
|
const r = parseInt(hex[1], 16) / 255;
|
||||||
|
const g = parseInt(hex[2], 16) / 255;
|
||||||
|
const b = parseInt(hex[3], 16) / 255;
|
||||||
|
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
return lum > 0.45 ? "#0a0a0a" : "#ffffff";
|
||||||
|
}
|
||||||
|
return "#ffffff";
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
import { type Plugin } from "@/plugins/core/types";
|
import { type Plugin } from "@/plugins/core/types";
|
||||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
|
||||||
import {
|
import {
|
||||||
clearStuck,
|
clearStuck,
|
||||||
getClassByPattern,
|
getClassByPattern,
|
||||||
initStorage,
|
initStorage,
|
||||||
|
injectWeightingsTab,
|
||||||
letterToNumber,
|
letterToNumber,
|
||||||
parseAssessments,
|
parseAssessments,
|
||||||
processAssessments,
|
processAssessments,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
interface weightingsStorage {
|
interface weightingsStorage {
|
||||||
weightings: Record<string, string>;
|
weightings: Record<string, string>;
|
||||||
assessments: Record<string, string>;
|
assessments: Record<string, string>;
|
||||||
|
weightingOverrides: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = defineSettings({
|
const settings = defineSettings({
|
||||||
@@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
|||||||
|
|
||||||
const instance = new AssessmentsAveragePluginClass();
|
const instance = new AssessmentsAveragePluginClass();
|
||||||
|
|
||||||
|
let overrideListenerController: AbortController | null = null;
|
||||||
|
|
||||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||||
id: "assessments-average",
|
id: "assessments-average",
|
||||||
name: "Assessment Averages",
|
name: "Assessment Averages",
|
||||||
@@ -58,143 +61,149 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await parseAssessments(api);
|
await parseAssessments(api);
|
||||||
|
await renderSubjectAverage(api);
|
||||||
const sampleAssessmentItem = document.querySelector(
|
overrideListenerController?.abort();
|
||||||
"[class*='AssessmentItem__AssessmentItem___']",
|
overrideListenerController = new AbortController();
|
||||||
|
document.addEventListener(
|
||||||
|
"betterseqta:overrideChanged",
|
||||||
|
() => renderSubjectAverage(api),
|
||||||
|
{ signal: overrideListenerController.signal },
|
||||||
);
|
);
|
||||||
if (!sampleAssessmentItem) return;
|
|
||||||
|
|
||||||
const assessmentItemClass =
|
|
||||||
Array.from(sampleAssessmentItem.classList).find((c) =>
|
|
||||||
c.startsWith("AssessmentItem__AssessmentItem___"),
|
|
||||||
) || "";
|
|
||||||
|
|
||||||
const metaContainerClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__metaContainer___",
|
|
||||||
);
|
|
||||||
const metaClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__meta___",
|
|
||||||
);
|
|
||||||
const simpleResultClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__simpleResult___",
|
|
||||||
);
|
|
||||||
const titleClass = getClassByPattern(
|
|
||||||
sampleAssessmentItem,
|
|
||||||
"AssessmentItem__title___",
|
|
||||||
);
|
|
||||||
|
|
||||||
const thermoscoreElement = document.querySelector(
|
|
||||||
"[class*='Thermoscore__Thermoscore___']",
|
|
||||||
);
|
|
||||||
if (!thermoscoreElement) return;
|
|
||||||
|
|
||||||
const thermoscoreClass =
|
|
||||||
Array.from(thermoscoreElement.classList).find((c) =>
|
|
||||||
c.startsWith("Thermoscore__Thermoscore___"),
|
|
||||||
) || "";
|
|
||||||
const fillClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__fill___",
|
|
||||||
);
|
|
||||||
const textClass = getClassByPattern(
|
|
||||||
thermoscoreElement,
|
|
||||||
"Thermoscore__text___",
|
|
||||||
);
|
|
||||||
|
|
||||||
const assessmentsList = document.querySelector(
|
|
||||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
|
||||||
);
|
|
||||||
if (!assessmentsList) return;
|
|
||||||
|
|
||||||
const state = await ReactFiber.find(
|
|
||||||
"[class*='AssessmentList__items___']",
|
|
||||||
).getState();
|
|
||||||
const marks = state["marks"];
|
|
||||||
if (!marks || !marks.length) return;
|
|
||||||
|
|
||||||
const assessmentItems = Array.from(
|
|
||||||
assessmentsList.querySelectorAll(
|
|
||||||
`[class*='AssessmentItem__AssessmentItem___']`,
|
|
||||||
),
|
|
||||||
).filter(
|
|
||||||
(item) =>
|
|
||||||
!item
|
|
||||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
|
||||||
?.textContent?.includes("Subject Average"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
|
||||||
await processAssessments(api, assessmentItems);
|
|
||||||
|
|
||||||
if (!count || totalWeight === 0) return;
|
|
||||||
|
|
||||||
const avg = weightedTotal / totalWeight;
|
|
||||||
const rounded = Math.ceil(avg / 5) * 5;
|
|
||||||
const numberToLetter = Object.entries(letterToNumber).reduce(
|
|
||||||
(acc, [k, v]) => {
|
|
||||||
acc[v] = k;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<number, string>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
|
||||||
const display = api.settings.lettergrade
|
|
||||||
? letterAvg
|
|
||||||
: `${avg.toFixed(2)}%`;
|
|
||||||
|
|
||||||
const existing = assessmentsList.querySelector(
|
|
||||||
`[class*='AssessmentItem__title___']`,
|
|
||||||
);
|
|
||||||
if (existing?.textContent === "Subject Average") return;
|
|
||||||
|
|
||||||
let warningHTML = "";
|
|
||||||
if (hasInaccurateWeighting) {
|
|
||||||
warningHTML = /* html */ `
|
|
||||||
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
|
||||||
⚠ Some weightings unavailable
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
assessmentsList.insertBefore(
|
|
||||||
stringToHTML(/* html */ `
|
|
||||||
<div class="${assessmentItemClass}">
|
|
||||||
<div class="${metaContainerClass}">
|
|
||||||
<div class="${metaClass}">
|
|
||||||
<div class="${simpleResultClass}">
|
|
||||||
<div class="${titleClass}">Subject Average</div>
|
|
||||||
${warningHTML}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="${thermoscoreClass}">
|
|
||||||
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
|
||||||
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`).firstChild!,
|
|
||||||
assessmentsList.firstChild,
|
|
||||||
);
|
|
||||||
|
|
||||||
applySubjectColourToOverallResult();
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
applySubjectColourToOverallResult();
|
|
||||||
});
|
|
||||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
});
|
||||||
observer.observe(wrapper, { childList: true, subtree: true });
|
observer.observe(wrapper, { childList: true, subtree: true });
|
||||||
setTimeout(() => observer.disconnect(), 10000);
|
setTimeout(() => observer.disconnect(), 10000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||||
|
injectWeightingsTab(api);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let renderInFlight = false;
|
||||||
|
async function renderSubjectAverage(api: any) {
|
||||||
|
if (renderInFlight) return;
|
||||||
|
renderInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assessmentsList = document.querySelector(
|
||||||
|
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
|
||||||
|
);
|
||||||
|
if (!assessmentsList) return;
|
||||||
|
|
||||||
|
// Remove existing subject average before re-rendering
|
||||||
|
Array.from(
|
||||||
|
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
|
||||||
|
)
|
||||||
|
.find((el) => el.textContent === "Subject Average")
|
||||||
|
?.closest("[class*='AssessmentItem__AssessmentItem___']")
|
||||||
|
?.remove();
|
||||||
|
|
||||||
|
const sampleAssessmentItem = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___']",
|
||||||
|
);
|
||||||
|
if (!sampleAssessmentItem) return;
|
||||||
|
const assessmentItemClass =
|
||||||
|
Array.from(sampleAssessmentItem.classList).find((c) =>
|
||||||
|
c.startsWith("AssessmentItem__AssessmentItem___"),
|
||||||
|
) || "";
|
||||||
|
const metaContainerClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__metaContainer___",
|
||||||
|
);
|
||||||
|
const metaClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__meta___",
|
||||||
|
);
|
||||||
|
const simpleResultClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__simpleResult___",
|
||||||
|
);
|
||||||
|
const titleClass = getClassByPattern(
|
||||||
|
sampleAssessmentItem,
|
||||||
|
"AssessmentItem__title___",
|
||||||
|
);
|
||||||
|
|
||||||
|
const assessmentItems = Array.from(
|
||||||
|
assessmentsList.querySelectorAll(
|
||||||
|
`[class*='AssessmentItem__AssessmentItem___']`,
|
||||||
|
),
|
||||||
|
).filter(
|
||||||
|
(item) =>
|
||||||
|
!item
|
||||||
|
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||||
|
?.textContent?.includes("Subject Average"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
|
||||||
|
await processAssessments(api, assessmentItems);
|
||||||
|
if (!count || totalWeight === 0) return;
|
||||||
|
|
||||||
|
const thermoscoreElement = document.querySelector(
|
||||||
|
"[class*='Thermoscore__Thermoscore___']",
|
||||||
|
);
|
||||||
|
if (!thermoscoreElement) return;
|
||||||
|
const thermoscoreClass =
|
||||||
|
Array.from(thermoscoreElement.classList).find((c) =>
|
||||||
|
c.startsWith("Thermoscore__Thermoscore___"),
|
||||||
|
) || "";
|
||||||
|
const fillClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__fill___",
|
||||||
|
);
|
||||||
|
const textClass = getClassByPattern(
|
||||||
|
thermoscoreElement,
|
||||||
|
"Thermoscore__text___",
|
||||||
|
);
|
||||||
|
|
||||||
|
const avg = weightedTotal / totalWeight;
|
||||||
|
const rounded = Math.ceil(avg / 5) * 5;
|
||||||
|
const numberToLetter = Object.entries(letterToNumber).reduce(
|
||||||
|
(acc, [k, v]) => {
|
||||||
|
acc[v] = k;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<number, string>,
|
||||||
|
);
|
||||||
|
const letterAvg = numberToLetter[rounded] ?? "N/A";
|
||||||
|
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`;
|
||||||
|
let warningHTML = "";
|
||||||
|
if (hasInaccurateWeighting) {
|
||||||
|
warningHTML = /* html */ `
|
||||||
|
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
|
||||||
|
⚠ Some weightings unavailable
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
assessmentsList.insertBefore(
|
||||||
|
stringToHTML(/* html */ `
|
||||||
|
<div class="${assessmentItemClass}">
|
||||||
|
<div class="${metaContainerClass}">
|
||||||
|
<div class="${metaClass}">
|
||||||
|
<div class="${simpleResultClass}">
|
||||||
|
<div class="${titleClass}">Subject Average</div>
|
||||||
|
${warningHTML}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="${thermoscoreClass}">
|
||||||
|
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
|
||||||
|
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).firstChild!,
|
||||||
|
assessmentsList.firstChild,
|
||||||
|
);
|
||||||
|
applySubjectColourToOverallResult();
|
||||||
|
} finally {
|
||||||
|
renderInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
function applySubjectColourToOverallResult() {
|
function applySubjectColourToOverallResult() {
|
||||||
const selectedAssessmentItem = document.querySelector(
|
const selectedAssessmentItem = document.querySelector(
|
||||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function initStorage(api: any) {
|
|||||||
if (!api.storage.assessments) {
|
if (!api.storage.assessments) {
|
||||||
api.storage.assessments = {};
|
api.storage.assessments = {};
|
||||||
}
|
}
|
||||||
|
if (!api.storage.weightingOverrides) {
|
||||||
|
api.storage.weightingOverrides = {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStuck(api: any) {
|
export function clearStuck(api: any) {
|
||||||
@@ -79,52 +82,84 @@ function createWeightLabel(
|
|||||||
assessmentItem: Element,
|
assessmentItem: Element,
|
||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
) {
|
) {
|
||||||
const statsContainer = assessmentItem.querySelector(
|
let statsContainer = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__stats___']`,
|
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||||
) as HTMLElement;
|
) as HTMLElement | null;
|
||||||
|
|
||||||
if (
|
if (!statsContainer) {
|
||||||
!statsContainer ||
|
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
|
||||||
statsContainer.querySelector(".betterseqta-weight-label")
|
statsContainer = document.createElement("div");
|
||||||
)
|
statsContainer.className = statsClass;
|
||||||
return;
|
statsContainer.classList.add("betterseqta-stats-container");
|
||||||
|
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
|
||||||
|
if (thermoscore) {
|
||||||
|
thermoscore.insertAdjacentElement("afterend", statsContainer);
|
||||||
|
} else {
|
||||||
|
assessmentItem.appendChild(statsContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const label = statsContainer.querySelector(
|
const hasNativeLabel = !!statsContainer.querySelector(
|
||||||
`[class*='Label__Label___']`,
|
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
if (!label) return;
|
|
||||||
|
|
||||||
const weightLabel = label.cloneNode(true) as HTMLElement;
|
|
||||||
weightLabel.classList.add("betterseqta-weight-label");
|
|
||||||
|
|
||||||
const innerTextDiv = weightLabel.querySelector(
|
|
||||||
`[class*='Label__innerText___']`,
|
|
||||||
);
|
);
|
||||||
|
statsContainer.style.justifyContent = hasNativeLabel
|
||||||
|
? "space-between"
|
||||||
|
: "flex-end";
|
||||||
|
|
||||||
|
const displayText =
|
||||||
|
weighting && weighting !== "processing" && weighting !== "N/A"
|
||||||
|
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
const existingLabel = statsContainer.querySelector(
|
||||||
|
".betterseqta-weight-label",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (existingLabel) {
|
||||||
|
const textNodes = Array.from(existingLabel.childNodes).filter(
|
||||||
|
(node) => node.nodeType === Node.TEXT_NODE,
|
||||||
|
);
|
||||||
|
if (textNodes.length) textNodes[0].textContent = displayText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statsContainer.style.display = "flex";
|
||||||
|
statsContainer.style.alignItems = "center";
|
||||||
|
statsContainer.style.width = "100%";
|
||||||
|
|
||||||
|
// Try to clone an existing label from the stats container first,
|
||||||
|
// fall back to building from scratch if none exists
|
||||||
|
const existingNativeLabel = statsContainer.querySelector(
|
||||||
|
`[class*='Label__Label___']`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
const weightLabel = existingNativeLabel
|
||||||
|
? (existingNativeLabel.cloneNode(true) as HTMLElement)
|
||||||
|
: (() => {
|
||||||
|
const labelClass = getClassByPattern(document, "Label__Label___");
|
||||||
|
const innerTextClass = getClassByPattern(document, "Label__innerText___");
|
||||||
|
const el = document.createElement("label");
|
||||||
|
el.className = labelClass;
|
||||||
|
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
weightLabel.classList.add("betterseqta-weight-label");
|
||||||
|
weightLabel.style.flex = "none";
|
||||||
|
weightLabel.style.width = "fit-content";
|
||||||
|
|
||||||
|
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||||
|
|
||||||
const textNodes = Array.from(weightLabel.childNodes).filter(
|
const textNodes = Array.from(weightLabel.childNodes).filter(
|
||||||
(node) => node.nodeType === Node.TEXT_NODE,
|
(node) => node.nodeType === Node.TEXT_NODE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (textNodes.length) {
|
if (textNodes.length) {
|
||||||
textNodes[0].textContent =
|
textNodes[0].textContent = displayText;
|
||||||
weighting && weighting !== "processing"
|
} else {
|
||||||
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
weightLabel.appendChild(document.createTextNode(displayText));
|
||||||
: "N/A";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
|
|
||||||
statsContainer.style.display = "flex";
|
|
||||||
statsContainer.style.flexDirection = "column";
|
|
||||||
statsContainer.style.alignItems = "flex-end";
|
|
||||||
statsContainer.style.gap = "2px";
|
|
||||||
statsContainer.style.justifyContent = "center";
|
|
||||||
|
|
||||||
weightLabel.style.position = "relative";
|
|
||||||
weightLabel.style.inset = "unset";
|
|
||||||
weightLabel.style.transform = "none";
|
|
||||||
|
|
||||||
statsContainer.appendChild(weightLabel);
|
statsContainer.appendChild(weightLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +263,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
|||||||
export async function extractPDFText(url: string): Promise<string> {
|
export async function extractPDFText(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||||
|
getPdfjsPageContextUrls();
|
||||||
const escJsSingleQuoted = (s: string) =>
|
const escJsSingleQuoted = (s: string) =>
|
||||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||||
@@ -519,7 +555,11 @@ export async function parseAssessments(api: any) {
|
|||||||
"[class*='AssessmentList__items___']",
|
"[class*='AssessmentList__items___']",
|
||||||
).getState();
|
).getState();
|
||||||
|
|
||||||
const marks = state["marks"];
|
const marks = [
|
||||||
|
...(state["marks"] ?? []),
|
||||||
|
...(state["upcoming"] ?? []),
|
||||||
|
...(state["pending"] ?? []),
|
||||||
|
];
|
||||||
if (!marks) return;
|
if (!marks) return;
|
||||||
|
|
||||||
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||||
@@ -532,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
for (const assessmentItem of assessmentItems) {
|
for (const assessmentItem of assessmentItems) {
|
||||||
const gradeElement = assessmentItem.querySelector(
|
|
||||||
`[class*='Thermoscore__text___']`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!gradeElement) continue;
|
|
||||||
|
|
||||||
const grade = parseGrade(gradeElement.textContent || "");
|
|
||||||
if (grade <= 0) continue;
|
|
||||||
|
|
||||||
const titleEl = assessmentItem.querySelector(
|
const titleEl = assessmentItem.querySelector(
|
||||||
`[class*='AssessmentItem__title___']`,
|
`[class*='AssessmentItem__title___']`,
|
||||||
);
|
);
|
||||||
@@ -550,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
const assessmentID = api.storage.assessments?.[title];
|
const assessmentID = api.storage.assessments?.[title];
|
||||||
const weighting = assessmentID
|
const autoWeighting = assessmentID
|
||||||
? api.storage.weightings?.[assessmentID]
|
? api.storage.weightings?.[assessmentID]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
const weighting = override ?? autoWeighting;
|
||||||
|
|
||||||
createWeightLabel(assessmentItem, weighting);
|
createWeightLabel(assessmentItem, weighting);
|
||||||
|
|
||||||
|
const gradeElement = assessmentItem.querySelector(
|
||||||
|
`[class*='Thermoscore__text___']`,
|
||||||
|
);
|
||||||
|
if (!gradeElement) continue;
|
||||||
|
const grade = parseGrade(gradeElement.textContent || "");
|
||||||
|
if (grade <= 0) continue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
weighting === null ||
|
weighting === null ||
|
||||||
weighting === undefined ||
|
weighting === undefined ||
|
||||||
@@ -563,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
weighting === "processing"
|
weighting === "processing"
|
||||||
) {
|
) {
|
||||||
hasInaccurateWeighting = true;
|
hasInaccurateWeighting = true;
|
||||||
weightedTotal += grade;
|
continue
|
||||||
totalWeight += 1;
|
|
||||||
} else {
|
} else {
|
||||||
const weight = parseFloat(weighting);
|
const weight = parseFloat(weighting);
|
||||||
|
|
||||||
@@ -587,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
|||||||
count,
|
count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTabSetClasses(): Record<string, string> {
|
||||||
|
const patterns = [
|
||||||
|
"TabSet__tabsheet___",
|
||||||
|
"TabSet__hidden___",
|
||||||
|
"TabSet__selected___",
|
||||||
|
"TabSet__disappearToLeft___",
|
||||||
|
"TabSet__disappearToRight___",
|
||||||
|
"TabSet__appearFromRight___",
|
||||||
|
"TabSet__appearFromLeft___",
|
||||||
|
];
|
||||||
|
|
||||||
|
const resolved: Record<string, string> = {};
|
||||||
|
|
||||||
|
// First pass: scan live DOM elements (fast, covers currently-applied classes)
|
||||||
|
const allClasses = Array.from(
|
||||||
|
document.querySelectorAll('[class*="TabSet__"]'),
|
||||||
|
).flatMap((el) => Array.from(el.classList));
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = allClasses.find((c) => c.startsWith(pattern));
|
||||||
|
if (found) resolved[pattern] = found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: scan stylesheets for any classes not yet in the DOM
|
||||||
|
// (e.g. animation classes that haven't been applied yet)
|
||||||
|
const missing = patterns.filter((p) => !resolved[p]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const sheet of Array.from(document.styleSheets)) {
|
||||||
|
if (missing.every((p) => resolved[p])) break;
|
||||||
|
try {
|
||||||
|
for (const rule of Array.from(sheet.cssRules ?? [])) {
|
||||||
|
if (!(rule instanceof CSSStyleRule)) continue;
|
||||||
|
const selectorClasses =
|
||||||
|
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
|
||||||
|
for (const pattern of missing) {
|
||||||
|
if (!resolved[pattern]) {
|
||||||
|
const match = selectorClasses.find((c) =>
|
||||||
|
c.slice(1).startsWith(pattern),
|
||||||
|
);
|
||||||
|
if (match) resolved[pattern] = match.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cross-origin stylesheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use the base pattern as-is so the function doesn't crash,
|
||||||
|
// though styles won't apply if the hash is truly unknown.
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (!resolved[pattern]) resolved[pattern] = pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||||
|
const titleEl = document.querySelector(
|
||||||
|
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
|
||||||
|
);
|
||||||
|
const title = titleEl?.textContent?.trim();
|
||||||
|
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||||
|
|
||||||
|
const rawWeight = assessmentID
|
||||||
|
? api.storage.weightings?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const weightingUnavailable = rawWeight === "N/A";
|
||||||
|
|
||||||
|
const autoWeight =
|
||||||
|
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
|
||||||
|
? rawWeight
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const override = assessmentID
|
||||||
|
? api.storage.weightingOverrides?.[assessmentID]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const statusNote = !assessmentID
|
||||||
|
? ""
|
||||||
|
: rawWeight === "processing"
|
||||||
|
? "Weighting is still being detected."
|
||||||
|
: weightingUnavailable
|
||||||
|
? "No weighting was found in the marksheet. Set one manually."
|
||||||
|
: "Overrides the auto-detected value.";
|
||||||
|
|
||||||
|
sheet.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#betterseqta-weight-override::placeholder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="padding:16px;max-width:360px">
|
||||||
|
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
|
||||||
|
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
|
||||||
|
Set the weighting for this assessment.
|
||||||
|
${statusNote}
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||||
|
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
|
||||||
|
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
|
||||||
|
<input
|
||||||
|
id="betterseqta-weight-override"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="5"
|
||||||
|
placeholder="${autoWeight ?? ""}"
|
||||||
|
value="${override ?? ""}"
|
||||||
|
${!assessmentID ? "disabled" : ""}
|
||||||
|
style="
|
||||||
|
width:90px;
|
||||||
|
padding:5px 8px;
|
||||||
|
border-radius:6px;
|
||||||
|
border:1px solid rgba(128,128,128,0.3);
|
||||||
|
background:rgba(128,128,128,0.08);
|
||||||
|
color:inherit;
|
||||||
|
font-size:13px;
|
||||||
|
outline:none;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px;min-height:18px">
|
||||||
|
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
|
||||||
|
</div>
|
||||||
|
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!assessmentID) return;
|
||||||
|
|
||||||
|
const input = sheet.querySelector(
|
||||||
|
"#betterseqta-weight-override",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const statusEl = sheet.querySelector(
|
||||||
|
".betterseqta-save-status",
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const raw = input.value.trim();
|
||||||
|
if (raw === "") {
|
||||||
|
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
|
||||||
|
api.storage.weightingOverrides = rest;
|
||||||
|
} else {
|
||||||
|
const val = parseFloat(raw);
|
||||||
|
if (isNaN(val) || val < 0) {
|
||||||
|
input.style.borderColor = "rgba(255,80,80,0.6)";
|
||||||
|
statusEl.textContent = "Invalid. Must be 0 or greater";
|
||||||
|
statusEl.style.color = "rgba(255,80,80,0.8)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
api.storage.weightingOverrides = {
|
||||||
|
...api.storage.weightingOverrides,
|
||||||
|
[assessmentID]: String(val),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
statusEl.textContent = "Saved";
|
||||||
|
statusEl.style.color = "";
|
||||||
|
setTimeout(() => (statusEl.textContent = ""), 2000);
|
||||||
|
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("blur", save);
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
input.blur();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
input.style.borderColor = "rgba(128,128,128,0.3)";
|
||||||
|
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
|
||||||
|
statusEl.textContent = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectWeightingsTab(api: any) {
|
||||||
|
const tabList = document.querySelector(
|
||||||
|
'[class*="TabSet__tabs___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
const container = document.querySelector(
|
||||||
|
'[class*="TabSet__tabContainer___"]',
|
||||||
|
) as HTMLElement;
|
||||||
|
if (!tabList || !container) return;
|
||||||
|
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
|
||||||
|
|
||||||
|
const cls = resolveTabSetClasses();
|
||||||
|
|
||||||
|
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
|
||||||
|
/-tab-\d+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const newIndex = tabList.querySelectorAll("li").length;
|
||||||
|
|
||||||
|
const newTab = document.createElement("li");
|
||||||
|
newTab.id = `${prefix}-tab-${newIndex}`;
|
||||||
|
newTab.className = "";
|
||||||
|
newTab.setAttribute("aria-selected", "false");
|
||||||
|
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
|
||||||
|
newTab.classList.add("betterseqta-weightings-tab");
|
||||||
|
newTab.textContent = "Weightings";
|
||||||
|
tabList.appendChild(newTab);
|
||||||
|
|
||||||
|
const newSheet = document.createElement("div");
|
||||||
|
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
|
||||||
|
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
|
||||||
|
newSheet.className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
container.appendChild(newSheet);
|
||||||
|
|
||||||
|
let populated = false;
|
||||||
|
newTab.addEventListener("click", () => {
|
||||||
|
if (!populated) {
|
||||||
|
buildWeightingsTabContent(api, newSheet);
|
||||||
|
populated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTabs = Array.from(tabList.querySelectorAll("li"));
|
||||||
|
const allSheets = Array.from(
|
||||||
|
container.querySelectorAll('[class*="tabsheet"]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
allTabs.forEach((tab, i) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
const currentIndex = allTabs.findIndex((t) =>
|
||||||
|
t.className.includes("TabSet__selected___"),
|
||||||
|
);
|
||||||
|
if (i === currentIndex) return;
|
||||||
|
const goingRight = i > currentIndex;
|
||||||
|
|
||||||
|
allTabs.forEach((t) => {
|
||||||
|
t.className = "";
|
||||||
|
t.setAttribute("aria-selected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
allSheets[currentIndex].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__hidden___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__disappearToLeft___"]
|
||||||
|
: cls["TabSet__disappearToRight___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
allSheets[i].className = [
|
||||||
|
cls["TabSet__tabsheet___"],
|
||||||
|
cls["TabSet__selected___"],
|
||||||
|
goingRight
|
||||||
|
? cls["TabSet__appearFromRight___"]
|
||||||
|
: cls["TabSet__appearFromLeft___"],
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
tab.className = cls["TabSet__selected___"];
|
||||||
|
tab.setAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* BetterSEQTA Security — core XSS-focused protections for HTML rendered from SEQTA APIs.
|
||||||
|
*
|
||||||
|
* Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`.
|
||||||
|
* Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"`
|
||||||
|
* (without `allow-scripts`) on those iframes so script execution is suppressed while we can
|
||||||
|
* still read `contentDocument` for scanning and existing theme/CSS injection.
|
||||||
|
*
|
||||||
|
* The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so
|
||||||
|
* React replacing `.uiFrameWrapper` / iframe siblings does not destroy it.
|
||||||
|
*/
|
||||||
|
import type { Plugin } from "../../core/types";
|
||||||
|
import {
|
||||||
|
analyzeHtmlThreats,
|
||||||
|
type ThreatAnalysis,
|
||||||
|
} from "@/seqta/security/analyzeHtmlThreats";
|
||||||
|
import {
|
||||||
|
mountBlockedContentUi,
|
||||||
|
SECURITY_MESSAGE_OVERLAY_CLASS,
|
||||||
|
} from "@/seqta/security/blockedContentUi";
|
||||||
|
import { eventManager } from "@/seqta/utils/listeners/EventManager";
|
||||||
|
|
||||||
|
const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe";
|
||||||
|
|
||||||
|
const userHtmlIframeLoadHooked = new WeakSet<HTMLIFrameElement>();
|
||||||
|
|
||||||
|
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
|
||||||
|
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => void>();
|
||||||
|
|
||||||
|
function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void {
|
||||||
|
const fn = messageOverlayCleanups.get(iframe);
|
||||||
|
if (fn) {
|
||||||
|
fn();
|
||||||
|
messageOverlayCleanups.delete(iframe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void {
|
||||||
|
if (iframe.dataset.bssUserHtmlSandbox === "1") return;
|
||||||
|
iframe.dataset.bssUserHtmlSandbox = "1";
|
||||||
|
iframe.setAttribute("sandbox", "allow-same-origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeIframeDocument(iframe: HTMLIFrameElement): void {
|
||||||
|
try {
|
||||||
|
const d = iframe.contentDocument;
|
||||||
|
if (!d) return;
|
||||||
|
d.open();
|
||||||
|
d.write(
|
||||||
|
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
|
||||||
|
);
|
||||||
|
d.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After we replace a malicious document, the iframe fires `load` again with this blank shell.
|
||||||
|
* That pass must not tear down the blocker UI or the iframe would “recover” for one frame.
|
||||||
|
*/
|
||||||
|
function isPostWipeBlankDocument(doc: Document): boolean {
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body || body.childElementCount > 0) return false;
|
||||||
|
if ((body.textContent ?? "").trim().length > 0) return false;
|
||||||
|
const meta = doc.head?.querySelector('meta[charset="utf-8"]');
|
||||||
|
if (!meta) return false;
|
||||||
|
return doc.documentElement.outerHTML.length < 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe
|
||||||
|
* markup without removing this node.
|
||||||
|
*/
|
||||||
|
function mountBodyAnchoredMessageOverlay(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
anchor: HTMLElement,
|
||||||
|
opts: {
|
||||||
|
analysis: ThreatAnalysis;
|
||||||
|
rawSnippet: string;
|
||||||
|
contextTitle?: string;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
teardownMessageSecurityOverlay(iframe);
|
||||||
|
|
||||||
|
const shell = document.createElement("div");
|
||||||
|
shell.className = SECURITY_MESSAGE_OVERLAY_CLASS;
|
||||||
|
Object.assign(shell.style, {
|
||||||
|
position: "fixed",
|
||||||
|
zIndex: "2147483646",
|
||||||
|
overflow: "hidden",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
padding: "12px",
|
||||||
|
background: "rgba(24,24,27,0.35)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
Object.assign(inner.style, {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
});
|
||||||
|
shell.appendChild(inner);
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
const syncRect = (): void => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
if (!iframe.isConnected) {
|
||||||
|
teardownMessageSecurityOverlay(iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!anchor.isConnected) {
|
||||||
|
teardownMessageSecurityOverlay(iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
const pad = 10;
|
||||||
|
const left = Math.max(8, r.left - pad);
|
||||||
|
const top = Math.max(8, r.top - pad);
|
||||||
|
const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2);
|
||||||
|
const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2);
|
||||||
|
shell.style.left = `${left}px`;
|
||||||
|
shell.style.top = `${top}px`;
|
||||||
|
shell.style.width = `${Math.max(0, width)}px`;
|
||||||
|
shell.style.height = `${Math.max(0, height)}px`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
syncRect();
|
||||||
|
document.body.appendChild(shell);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(syncRect);
|
||||||
|
ro.observe(anchor);
|
||||||
|
|
||||||
|
window.addEventListener("resize", syncRect);
|
||||||
|
window.addEventListener("scroll", syncRect, true);
|
||||||
|
|
||||||
|
const unmountPanel = mountBlockedContentUi(inner, {
|
||||||
|
surface: "message",
|
||||||
|
analysis: opts.analysis,
|
||||||
|
rawSnippet: opts.rawSnippet,
|
||||||
|
contextTitle: opts.contextTitle,
|
||||||
|
rootOverlay: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener("resize", syncRect);
|
||||||
|
window.removeEventListener("scroll", syncRect, true);
|
||||||
|
unmountPanel();
|
||||||
|
shell.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
messageOverlayCleanups.set(iframe, cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void {
|
||||||
|
let idoc: Document | null = null;
|
||||||
|
try {
|
||||||
|
idoc = iframe.contentDocument;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!idoc?.documentElement) return;
|
||||||
|
|
||||||
|
const wrapper =
|
||||||
|
iframe.closest(".uiFrameWrapper") ??
|
||||||
|
iframe.closest(".iframeWrapper") ??
|
||||||
|
iframe.parentElement;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
iframe.dataset.bssAwaitingWipeLoad === "1" &&
|
||||||
|
isPostWipeBlankDocument(idoc)
|
||||||
|
) {
|
||||||
|
iframe.dataset.bssAwaitingWipeLoad = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.dataset.bssAwaitingWipeLoad = "";
|
||||||
|
|
||||||
|
teardownMessageSecurityOverlay(iframe);
|
||||||
|
|
||||||
|
iframe.style.visibility = "";
|
||||||
|
iframe.style.height = "";
|
||||||
|
iframe.style.minHeight = "";
|
||||||
|
|
||||||
|
const html = idoc.documentElement.outerHTML;
|
||||||
|
const analysis = analyzeHtmlThreats(html);
|
||||||
|
if (!analysis.blocked) return;
|
||||||
|
|
||||||
|
const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]');
|
||||||
|
const anchor = (pane ?? wrapper) as HTMLElement;
|
||||||
|
|
||||||
|
iframe.dataset.bssAwaitingWipeLoad = "1";
|
||||||
|
wipeIframeDocument(iframe);
|
||||||
|
iframe.style.visibility = "hidden";
|
||||||
|
iframe.style.height = "0";
|
||||||
|
iframe.style.minHeight = "0";
|
||||||
|
|
||||||
|
const subject = pane
|
||||||
|
?.querySelector('[class*="Message__subject___"]')
|
||||||
|
?.textContent?.trim();
|
||||||
|
|
||||||
|
mountBodyAnchoredMessageOverlay(iframe, anchor, {
|
||||||
|
analysis,
|
||||||
|
rawSnippet: html.slice(0, 50_000),
|
||||||
|
contextTitle: subject,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const betterSeqtaSecurityPlugin: Plugin = {
|
||||||
|
id: "better-seqta-security",
|
||||||
|
name: "BetterSEQTA Security",
|
||||||
|
description:
|
||||||
|
"Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: {},
|
||||||
|
|
||||||
|
run: () => {
|
||||||
|
const { unregister } = eventManager.register(
|
||||||
|
USER_HTML_IFRAME_EVENT,
|
||||||
|
{
|
||||||
|
elementType: "iframe",
|
||||||
|
customCheck: (element) => element.classList.contains("userHTML"),
|
||||||
|
},
|
||||||
|
(element) => {
|
||||||
|
const iframe = element as HTMLIFrameElement;
|
||||||
|
if (userHtmlIframeLoadHooked.has(iframe)) return;
|
||||||
|
userHtmlIframeLoadHooked.add(iframe);
|
||||||
|
applyMessageIframeSandbox(iframe);
|
||||||
|
|
||||||
|
const onLoad = () => handleUserHtmlIframeLoaded(iframe);
|
||||||
|
iframe.addEventListener("load", onLoad);
|
||||||
|
queueMicrotask(onLoad);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return unregister;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default betterSeqtaSecurityPlugin;
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
import type { Plugin } from "../../core/types";
|
||||||
|
import { booleanSetting } from "@/plugins/core/settingsHelpers";
|
||||||
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
|
import styles from "./styles.css?inline";
|
||||||
|
|
||||||
|
const messageFoldersSettings = {
|
||||||
|
showTagsInAllMessages: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Show folder tags in All Messages",
|
||||||
|
description:
|
||||||
|
"When off, folder tags are not shown on the message list until you select a folder.",
|
||||||
|
}),
|
||||||
|
hideFolderedMessagesInAll: booleanSetting({
|
||||||
|
default: true,
|
||||||
|
title: "Hide foldered messages in All Messages",
|
||||||
|
description:
|
||||||
|
"When on, messages assigned to a custom folder are hidden from the inbox until you open that folder.",
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageFoldersStorage {
|
||||||
|
folders: Folder[];
|
||||||
|
messageAssignments: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOLDER_COLORS = [
|
||||||
|
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b",
|
||||||
|
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
|
||||||
|
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
|
||||||
|
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
|
||||||
|
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
|
||||||
|
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
|
||||||
|
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
|
||||||
|
id: "messageFolders",
|
||||||
|
name: "Message Folders",
|
||||||
|
description: "Organize direct messages into custom folders",
|
||||||
|
version: "1.0.0",
|
||||||
|
settings: messageFoldersSettings,
|
||||||
|
disableToggle: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
|
||||||
|
run: async (api) => {
|
||||||
|
const styleEl = document.createElement("style");
|
||||||
|
styleEl.textContent = styles;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
|
||||||
|
await api.storage.loaded;
|
||||||
|
|
||||||
|
if (!api.storage.folders) api.storage.folders = [];
|
||||||
|
if (!api.storage.messageAssignments) api.storage.messageAssignments = {};
|
||||||
|
|
||||||
|
let activeFolderId: string | null = null;
|
||||||
|
let messageListObserver: MutationObserver | null = null;
|
||||||
|
let sidebarObserver: MutationObserver | null = null;
|
||||||
|
let actionsObserver: MutationObserver | null = null;
|
||||||
|
let openDropdown: HTMLElement | null = null;
|
||||||
|
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
|
const unregisters: Array<{ unregister: () => void }> = [];
|
||||||
|
|
||||||
|
// ── Storage accessors ──
|
||||||
|
|
||||||
|
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||||
|
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||||
|
|
||||||
|
const saveFolders = (folders: Folder[]) => {
|
||||||
|
api.storage.folders = [...folders];
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAssignments = (assignments: Record<string, string[]>) => {
|
||||||
|
api.storage.messageAssignments = { ...assignments };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageFolderIds = (messageId: string): string[] => {
|
||||||
|
const assignments = getAssignments();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [folderId, msgIds] of Object.entries(assignments)) {
|
||||||
|
if (msgIds.includes(messageId)) ids.push(folderId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMessageInFolder = (messageId: string, folderId: string) => {
|
||||||
|
const assignments = getAssignments();
|
||||||
|
if (!assignments[folderId]) assignments[folderId] = [];
|
||||||
|
const idx = assignments[folderId].indexOf(messageId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
assignments[folderId].splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
assignments[folderId].push(messageId);
|
||||||
|
}
|
||||||
|
saveAssignments(assignments);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderMessageCount = (folderId: string): number => {
|
||||||
|
return (getAssignments()[folderId] ?? []).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSubjectPlain = (subject: Element) => {
|
||||||
|
subject.querySelector(".bsplus-msg-badges")?.remove();
|
||||||
|
const textWrap = subject.querySelector(".bsplus-subject-text");
|
||||||
|
if (textWrap) {
|
||||||
|
subject.textContent = textWrap.textContent ?? "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMessageInAnyCustomFolder = (messageId: string): boolean => {
|
||||||
|
for (const msgIds of Object.values(getAssignments())) {
|
||||||
|
if (msgIds.includes(messageId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowBadgesInList = (): boolean => {
|
||||||
|
return api.settings.showTagsInAllMessages || activeFolderId !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Confirm modal ──
|
||||||
|
|
||||||
|
const showConfirmModal = (
|
||||||
|
title: string,
|
||||||
|
message: string,
|
||||||
|
onConfirm: () => void,
|
||||||
|
) => {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "bsplus-modal-overlay";
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.className = "bsplus-modal";
|
||||||
|
modal.innerHTML = `
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<p>${message}</p>
|
||||||
|
<div class="bsplus-modal-actions">
|
||||||
|
<button class="bsplus-modal-btn-cancel">Cancel</button>
|
||||||
|
<button class="bsplus-modal-btn-danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (e.target === overlay) remove();
|
||||||
|
});
|
||||||
|
modal.querySelector(".bsplus-modal-btn-cancel")!.addEventListener("click", remove);
|
||||||
|
modal.querySelector(".bsplus-modal-btn-danger")!.addEventListener("click", () => {
|
||||||
|
onConfirm();
|
||||||
|
remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Sidebar folder UI ──
|
||||||
|
|
||||||
|
const renderSidebarFolders = () => {
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (!ol) return;
|
||||||
|
|
||||||
|
let section = ol.querySelector(".bsplus-folders-section");
|
||||||
|
if (!section) {
|
||||||
|
section = document.createElement("div");
|
||||||
|
section.className = "bsplus-folders-section";
|
||||||
|
ol.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const existingInput = section.querySelector(".bsplus-folder-input");
|
||||||
|
const existingColors = section.querySelector(".bsplus-folder-colors");
|
||||||
|
|
||||||
|
section.innerHTML = "";
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "bsplus-folders-header";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = "Folders";
|
||||||
|
header.appendChild(label);
|
||||||
|
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.className = "bsplus-folders-add-btn";
|
||||||
|
addBtn.title = "New folder";
|
||||||
|
addBtn.innerHTML = PLUS_SVG;
|
||||||
|
addBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showNewFolderInput(section!);
|
||||||
|
});
|
||||||
|
header.appendChild(addBtn);
|
||||||
|
section.appendChild(header);
|
||||||
|
|
||||||
|
// "All Messages" item
|
||||||
|
const allItem = document.createElement("div");
|
||||||
|
allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
|
||||||
|
allItem.innerHTML = `
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||||
|
<span class="bsplus-folder-name">All Messages</span>
|
||||||
|
`;
|
||||||
|
allItem.addEventListener("click", () => {
|
||||||
|
activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
section.appendChild(allItem);
|
||||||
|
|
||||||
|
// Folder items
|
||||||
|
for (const folder of folders) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
|
||||||
|
item.dataset.folderId = folder.id;
|
||||||
|
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "bsplus-folder-dot";
|
||||||
|
dot.style.background = folder.color;
|
||||||
|
item.appendChild(dot);
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "bsplus-folder-name";
|
||||||
|
name.textContent = folder.name;
|
||||||
|
item.appendChild(name);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "bsplus-folder-actions";
|
||||||
|
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "bsplus-folder-action-btn";
|
||||||
|
editBtn.title = "Rename";
|
||||||
|
editBtn.innerHTML = EDIT_SVG;
|
||||||
|
editBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showEditFolderInput(section!, folder);
|
||||||
|
});
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement("button");
|
||||||
|
deleteBtn.className = "bsplus-folder-action-btn";
|
||||||
|
deleteBtn.title = "Delete";
|
||||||
|
deleteBtn.innerHTML = TRASH_SVG;
|
||||||
|
deleteBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showConfirmModal(
|
||||||
|
"Delete folder",
|
||||||
|
`Remove "${folder.name}"? Messages won't be deleted.`,
|
||||||
|
() => {
|
||||||
|
const folders = getFolders().filter((f) => f.id !== folder.id);
|
||||||
|
saveFolders(folders);
|
||||||
|
const assignments = getAssignments();
|
||||||
|
delete assignments[folder.id];
|
||||||
|
saveAssignments(assignments);
|
||||||
|
if (activeFolderId === folder.id) activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
item.appendChild(actions);
|
||||||
|
|
||||||
|
const count = document.createElement("span");
|
||||||
|
count.className = "bsplus-folder-count";
|
||||||
|
const c = getFolderMessageCount(folder.id);
|
||||||
|
count.textContent = c > 0 ? String(c) : "";
|
||||||
|
item.appendChild(count);
|
||||||
|
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
activeFolderId = folder.id;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore input if it was open
|
||||||
|
if (existingInput || existingColors) {
|
||||||
|
// Don't restore – let user re-trigger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNewFolderInput = (container: Element, editFolder?: Folder) => {
|
||||||
|
const existing = container.querySelector(".bsplus-folder-input");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
container.querySelector(".bsplus-folder-colors")?.remove();
|
||||||
|
|
||||||
|
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "bsplus-folder-input";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.placeholder = editFolder ? "Rename folder…" : "Folder name…";
|
||||||
|
input.value = editFolder?.name ?? "";
|
||||||
|
input.maxLength = 30;
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement("button");
|
||||||
|
confirmBtn.className = "bsplus-folder-input-confirm";
|
||||||
|
confirmBtn.innerHTML = CHECK_SVG_WHITE;
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement("button");
|
||||||
|
cancelBtn.className = "bsplus-folder-input-cancel";
|
||||||
|
cancelBtn.innerHTML = CLOSE_SVG;
|
||||||
|
|
||||||
|
row.appendChild(input);
|
||||||
|
row.appendChild(confirmBtn);
|
||||||
|
row.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
const colorRow = document.createElement("div");
|
||||||
|
colorRow.className = "bsplus-folder-colors";
|
||||||
|
for (const color of FOLDER_COLORS) {
|
||||||
|
const swatch = document.createElement("button");
|
||||||
|
swatch.className = `bsplus-folder-color-opt${color === selectedColor ? " bsplus-color-selected" : ""}`;
|
||||||
|
swatch.style.background = color;
|
||||||
|
swatch.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectedColor = color;
|
||||||
|
colorRow.querySelectorAll(".bsplus-folder-color-opt").forEach((s) =>
|
||||||
|
s.classList.toggle("bsplus-color-selected", (s as HTMLElement).style.background === color),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
colorRow.appendChild(swatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const name = input.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
if (editFolder) {
|
||||||
|
const folders = getFolders().map((f) =>
|
||||||
|
f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
|
||||||
|
);
|
||||||
|
saveFolders(folders);
|
||||||
|
} else {
|
||||||
|
const folder: Folder = { id: generateId(), name, color: selectedColor };
|
||||||
|
saveFolders([...getFolders(), folder]);
|
||||||
|
}
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
confirm();
|
||||||
|
});
|
||||||
|
cancelBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") confirm();
|
||||||
|
if (e.key === "Escape") renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(row);
|
||||||
|
container.appendChild(colorRow);
|
||||||
|
requestAnimationFrame(() => input.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEditFolderInput = (container: Element, folder: Folder) => {
|
||||||
|
showNewFolderInput(container, folder);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Intercept native sidebar clicks to clear folder filter ──
|
||||||
|
|
||||||
|
const attachNativeSidebarListeners = () => {
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (!ol) return;
|
||||||
|
|
||||||
|
ol.addEventListener("click", (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(".bsplus-folders-section")) return;
|
||||||
|
|
||||||
|
const li = target.closest("li");
|
||||||
|
if (li && ol.contains(li)) {
|
||||||
|
if (activeFolderId !== null) {
|
||||||
|
activeFolderId = null;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── "Add to folder" button in message action bar ──
|
||||||
|
|
||||||
|
const injectFolderButton = (actionsBar: Element) => {
|
||||||
|
if (actionsBar.querySelector(".bsplus-folder-btn")) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "bsplus-folder-btn";
|
||||||
|
wrapper.style.position = "relative";
|
||||||
|
wrapper.style.display = "inline-block";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
|
||||||
|
btn.className = btnClasses;
|
||||||
|
btn.title = "Add to folder";
|
||||||
|
btn.innerHTML = FOLDER_ICON_SVG;
|
||||||
|
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeDropdown();
|
||||||
|
|
||||||
|
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
|
||||||
|
const messageId = selectedMsg?.getAttribute("data-message");
|
||||||
|
if (!messageId) return;
|
||||||
|
|
||||||
|
showFolderDropdown(wrapper, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
|
||||||
|
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
|
||||||
|
if (moreMenu) {
|
||||||
|
actionsBar.insertBefore(wrapper, moreMenu);
|
||||||
|
} else {
|
||||||
|
actionsBar.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
|
||||||
|
const dropdown = document.createElement("div");
|
||||||
|
dropdown.className = "bsplus-folder-dropdown";
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const currentFolderIds = getMessageFolderIds(messageId);
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "bsplus-folder-dropdown-empty";
|
||||||
|
empty.textContent = "No folders yet";
|
||||||
|
dropdown.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
for (const folder of folders) {
|
||||||
|
const isChecked = currentFolderIds.includes(folder.id);
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
|
||||||
|
|
||||||
|
const check = document.createElement("div");
|
||||||
|
check.className = "bsplus-folder-dropdown-check";
|
||||||
|
check.style.borderColor = isChecked ? folder.color : "";
|
||||||
|
check.style.background = isChecked ? folder.color : "";
|
||||||
|
check.innerHTML = CHECK_SVG_WHITE;
|
||||||
|
|
||||||
|
const dot = document.createElement("div");
|
||||||
|
dot.className = "bsplus-folder-dot";
|
||||||
|
dot.style.background = folder.color;
|
||||||
|
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = folder.name;
|
||||||
|
|
||||||
|
item.appendChild(check);
|
||||||
|
item.appendChild(dot);
|
||||||
|
item.appendChild(name);
|
||||||
|
|
||||||
|
item.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMessageInFolder(messageId, folder.id);
|
||||||
|
|
||||||
|
const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
|
||||||
|
item.classList.toggle("bsplus-checked", nowChecked);
|
||||||
|
check.style.borderColor = nowChecked ? folder.color : "";
|
||||||
|
check.style.background = nowChecked ? folder.color : "";
|
||||||
|
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor.appendChild(dropdown);
|
||||||
|
openDropdown = dropdown;
|
||||||
|
|
||||||
|
dropdownCloseHandler = (e: MouseEvent) => {
|
||||||
|
if (!dropdown.contains(e.target as Node) && !anchor.contains(e.target as Node)) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener("click", dropdownCloseHandler!, true);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
if (openDropdown) {
|
||||||
|
openDropdown.remove();
|
||||||
|
openDropdown = null;
|
||||||
|
}
|
||||||
|
if (dropdownCloseHandler) {
|
||||||
|
document.removeEventListener("click", dropdownCloseHandler, true);
|
||||||
|
dropdownCloseHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Message badges ──
|
||||||
|
|
||||||
|
const applyBadges = () => {
|
||||||
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
|
|
||||||
|
if (!shouldShowBadgesInList()) {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||||
|
if (subject && (subject.querySelector(".bsplus-msg-badges") || subject.querySelector(".bsplus-subject-text"))) {
|
||||||
|
restoreSubjectPlain(subject);
|
||||||
|
} else {
|
||||||
|
li.querySelector(".bsplus-msg-badges")?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = getFolders();
|
||||||
|
const assignments = getAssignments();
|
||||||
|
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (!msgId) continue;
|
||||||
|
|
||||||
|
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
|
||||||
|
|
||||||
|
const folderIds = [];
|
||||||
|
for (const [fId, mIds] of Object.entries(assignments)) {
|
||||||
|
if (mIds.includes(msgId)) folderIds.push(fId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderIds.length === 0) {
|
||||||
|
badgeContainer?.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badgeContainer) {
|
||||||
|
badgeContainer = document.createElement("div");
|
||||||
|
badgeContainer.className = "bsplus-msg-badges";
|
||||||
|
const subject = li.querySelector("[class*='MessageList__subject___']");
|
||||||
|
if (subject) {
|
||||||
|
if (!subject.querySelector(".bsplus-subject-text")) {
|
||||||
|
const textWrap = document.createElement("span");
|
||||||
|
textWrap.className = "bsplus-subject-text";
|
||||||
|
textWrap.textContent = subject.textContent;
|
||||||
|
subject.textContent = "";
|
||||||
|
subject.appendChild(textWrap);
|
||||||
|
}
|
||||||
|
subject.appendChild(badgeContainer);
|
||||||
|
} else {
|
||||||
|
li.appendChild(badgeContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeContainer.innerHTML = "";
|
||||||
|
for (const fId of folderIds) {
|
||||||
|
const folder = folders.find((f) => f.id === fId);
|
||||||
|
if (!folder) continue;
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "bsplus-msg-badge";
|
||||||
|
badge.style.background = folder.color;
|
||||||
|
badge.textContent = folder.name;
|
||||||
|
badge.title = `Filter by "${folder.name}"`;
|
||||||
|
badge.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
activeFolderId = folder.id;
|
||||||
|
applyFolderFilter();
|
||||||
|
applyBadges();
|
||||||
|
renderSidebarFolders();
|
||||||
|
});
|
||||||
|
badgeContainer.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Folder filtering ──
|
||||||
|
|
||||||
|
const applyFolderFilter = () => {
|
||||||
|
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
|
||||||
|
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
|
||||||
|
|
||||||
|
if (activeFolderId === null) {
|
||||||
|
if (api.settings.hideFolderedMessagesInAll) {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (msgId && isMessageInAnyCustomFolder(msgId)) {
|
||||||
|
li.classList.add("bsplus-folder-hidden");
|
||||||
|
} else {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const li of messageItems) {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMsgIds = getAssignments()[activeFolderId] ?? [];
|
||||||
|
|
||||||
|
for (const li of messageItems) {
|
||||||
|
const msgId = li.getAttribute("data-message");
|
||||||
|
if (msgId && folderMsgIds.includes(msgId)) {
|
||||||
|
li.classList.remove("bsplus-folder-hidden");
|
||||||
|
} else {
|
||||||
|
li.classList.add("bsplus-folder-hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Observers ──
|
||||||
|
|
||||||
|
const setupMessageListObserver = () => {
|
||||||
|
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
|
||||||
|
if (!messageList || messageListObserver) return;
|
||||||
|
|
||||||
|
messageListObserver = new MutationObserver(() => {
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
});
|
||||||
|
messageListObserver.observe(messageList, { childList: true, subtree: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupActionsObserver = () => {
|
||||||
|
if (actionsObserver) return;
|
||||||
|
|
||||||
|
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
actionsObserver = new MutationObserver(() => {
|
||||||
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
|
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
|
||||||
|
injectFolderButton(actionsBar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsObserver.observe(target, { childList: true, subtree: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main page handler ──
|
||||||
|
|
||||||
|
const handleMessagesPage = async () => {
|
||||||
|
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
|
||||||
|
|
||||||
|
renderSidebarFolders();
|
||||||
|
attachNativeSidebarListeners();
|
||||||
|
|
||||||
|
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
|
||||||
|
applyBadges();
|
||||||
|
applyFolderFilter();
|
||||||
|
setupMessageListObserver();
|
||||||
|
|
||||||
|
// The actions bar only exists when a message is selected/open,
|
||||||
|
// so we observe the whole viewer for it to appear dynamically
|
||||||
|
setupActionsObserver();
|
||||||
|
|
||||||
|
// If a message is already selected, inject immediately
|
||||||
|
const actionsBar = document.querySelector("[class*='Message__actions___']");
|
||||||
|
if (actionsBar) injectFolderButton(actionsBar);
|
||||||
|
|
||||||
|
// Re-observe the sidebar for SEQTA re-renders
|
||||||
|
const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
|
||||||
|
if (sidebar && !sidebarObserver) {
|
||||||
|
sidebarObserver = new MutationObserver(() => {
|
||||||
|
const ol = sidebar.querySelector("ol");
|
||||||
|
if (ol && !ol.querySelector(".bsplus-folders-section")) {
|
||||||
|
renderSidebarFolders();
|
||||||
|
attachNativeSidebarListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sidebarObserver.observe(sidebar, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
|
||||||
|
unregisters.push(mountUnsub);
|
||||||
|
|
||||||
|
unregisters.push(
|
||||||
|
api.settings.onChange("showTagsInAllMessages", () => {
|
||||||
|
applyBadges();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
unregisters.push(
|
||||||
|
api.settings.onChange("hideFolderedMessagesInAll", () => {
|
||||||
|
applyFolderFilter();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const u of unregisters) u.unregister();
|
||||||
|
messageListObserver?.disconnect();
|
||||||
|
sidebarObserver?.disconnect();
|
||||||
|
actionsObserver?.disconnect();
|
||||||
|
closeDropdown();
|
||||||
|
styleEl.remove();
|
||||||
|
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
|
||||||
|
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
|
||||||
|
if (subject.querySelector(".bsplus-subject-text")) {
|
||||||
|
restoreSubjectPlain(subject);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".bsplus-folder-hidden").forEach((el) =>
|
||||||
|
el.classList.remove("bsplus-folder-hidden"),
|
||||||
|
);
|
||||||
|
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default messageFoldersPlugin;
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/* ── Sidebar folder section ── */
|
||||||
|
.bsplus-folders-section {
|
||||||
|
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-header span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-primary, #666);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-add-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folders-add-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder list items ── */
|
||||||
|
.bsplus-folder-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 8px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-folder-active {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item.bsplus-folder-active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--better-main, #007bff);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-item:hover .bsplus-folder-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-action-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.15)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline folder name input ── */
|
||||||
|
.bsplus-folder-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--background-secondary, #ccc);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--background-secondary, #f5f5f5);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input input:focus {
|
||||||
|
border-color: var(--better-main, #007bff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm,
|
||||||
|
.bsplus-folder-input-cancel {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm {
|
||||||
|
background: var(--better-main, #007bff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-confirm:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-cancel {
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-input-cancel:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Color picker row ── */
|
||||||
|
.bsplus-folder-colors {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt:hover {
|
||||||
|
transform: scale(1.25);
|
||||||
|
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt.bsplus-color-selected {
|
||||||
|
border-color: var(--text-primary, #333);
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 0 3px rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-color-opt.bsplus-color-selected:hover {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "Add to folder" button in message actions bar ── */
|
||||||
|
.bsplus-folder-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-btn svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder dropdown ── */
|
||||||
|
.bsplus-folder-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
border: 1px solid var(--background-secondary, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: bsplus-dropdown-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-dropdown-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item:hover {
|
||||||
|
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-check {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--background-secondary, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check {
|
||||||
|
background: var(--better-main, #007bff);
|
||||||
|
border-color: var(--better-main, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-check svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
color: white;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-item.bsplus-checked .bsplus-folder-dropdown-check svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-folder-dropdown-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #999);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Let primary column use available space instead of being clipped ── */
|
||||||
|
[class*='MessageList__primary___'] {
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Make subject line a flex row so badges sit inline ── */
|
||||||
|
[class*='MessageList__subject___'] {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subject text truncates to make room for badges ── */
|
||||||
|
.bsplus-subject-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shrink the secondary column to its content ── */
|
||||||
|
[class*='MessageList__secondary___'] {
|
||||||
|
flex: 0 0 auto !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 200px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Constrain the flags/attachment icon column ── */
|
||||||
|
[class*='MessageList__flags___'] {
|
||||||
|
width: 24px !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Message list folder badges ── */
|
||||||
|
.bsplus-msg-badges {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-msg-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-msg-badge:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder filtering (hide messages not in active folder) ── */
|
||||||
|
.bsplus-folder-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation modal ── */
|
||||||
|
@keyframes bsplus-modal-overlay-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bsplus-modal-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: bsplus-modal-overlay-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin: 0 1rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--background-primary, #fff);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--background-secondary, #e0e0e0);
|
||||||
|
animation: bsplus-modal-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary, #666);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-actions button {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--background-secondary, #ccc);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-cancel:hover {
|
||||||
|
background: var(--background-secondary, rgba(128, 128, 128, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-danger {
|
||||||
|
background: #e53e3e;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-modal-btn-danger:hover {
|
||||||
|
background: #c53030;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const themesPlugin: Plugin = {
|
|||||||
|
|
||||||
run: async (_) => {
|
run: async (_) => {
|
||||||
const themeManager = ThemeManager.getInstance();
|
const themeManager = ThemeManager.getInstance();
|
||||||
|
await themeManager.prepareThemeAfterCloudSync();
|
||||||
await themeManager.initialize();
|
await themeManager.initialize();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type LoadedCustomTheme,
|
type LoadedCustomTheme,
|
||||||
shouldForceThemeAppearance,
|
shouldForceThemeAppearance,
|
||||||
} from "@/types/CustomThemes";
|
} from "@/types/CustomThemes";
|
||||||
|
import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloudSettingsSync";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import debounce from "@/seqta/utils/debounce";
|
import debounce from "@/seqta/utils/debounce";
|
||||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||||
@@ -25,7 +26,9 @@ type ThemeContent = {
|
|||||||
CanChangeColour?: boolean;
|
CanChangeColour?: boolean;
|
||||||
CustomCSS?: string;
|
CustomCSS?: string;
|
||||||
hideThemeName?: boolean;
|
hideThemeName?: boolean;
|
||||||
|
forceTheme?: boolean;
|
||||||
forceDark?: boolean;
|
forceDark?: boolean;
|
||||||
|
adaptiveCssVariables?: string[];
|
||||||
images?: { id: string; variableName: string; data: string }[]; // data: base64
|
images?: { id: string; variableName: string; data: string }[]; // data: base64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ export type InstallThemeMeta = {
|
|||||||
serverUpdatedAtSec?: number;
|
serverUpdatedAtSec?: number;
|
||||||
forceTheme?: boolean;
|
forceTheme?: boolean;
|
||||||
adaptiveCssVariables?: string[];
|
adaptiveCssVariables?: string[];
|
||||||
images: { id: string; variableName: string; data: string }[]; // data: base64
|
images?: { id: string; variableName: string; data: string }[]; // data: base64
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ThemeManager {
|
export class ThemeManager {
|
||||||
@@ -164,6 +167,33 @@ export class ThemeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After cloud restore, IndexedDB/theme storage is only reachable from page context (not MV3 SW).
|
||||||
|
* Background sets BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY; we fetch the store JSON here before setTheme().
|
||||||
|
* The resolved id matches cloud sync **`themeId` / `selectedTheme`**: it may be a standard theme uuid or a
|
||||||
|
* flavour (slave) variant id — **`downloadAndInstallStoreTheme`** is the same code path as the theme store installer.
|
||||||
|
*/
|
||||||
|
public async prepareThemeAfterCloudSync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snap = await browser.storage.local.get(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
|
||||||
|
const pending = snap[BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY];
|
||||||
|
if (pending === undefined) return;
|
||||||
|
|
||||||
|
await browser.storage.local.remove(BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY);
|
||||||
|
|
||||||
|
if (typeof pending !== "string") return;
|
||||||
|
const id = pending.trim();
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const existing = (await localforage.getItem(id)) as CustomTheme | null;
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
await this.downloadAndInstallStoreTheme({ id, name: id });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[ThemeManager] prepareThemeAfterCloudSync:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the theme system and restore previous state
|
* Initialize the theme system and restore previous state
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
|||||||
import profilePicturePlugin from "./built-in/profilePicture";
|
import profilePicturePlugin from "./built-in/profilePicture";
|
||||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||||
|
import betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
|
||||||
|
//import messageFoldersPlugin from "./built-in/messageFolders";
|
||||||
//import testPlugin from './built-in/test';
|
//import testPlugin from './built-in/test';
|
||||||
|
|
||||||
// Heavy plugins (lazy-loaded only when enabled)
|
// Heavy plugins (lazy-loaded only when enabled)
|
||||||
@@ -20,6 +22,7 @@ const pluginManager = PluginManager.getInstance();
|
|||||||
|
|
||||||
// Register built-in plugins
|
// Register built-in plugins
|
||||||
pluginManager.registerPlugin(themesPlugin);
|
pluginManager.registerPlugin(themesPlugin);
|
||||||
|
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
|
||||||
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||||
@@ -28,6 +31,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
|
|||||||
pluginManager.registerPlugin(profilePicturePlugin);
|
pluginManager.registerPlugin(profilePicturePlugin);
|
||||||
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
pluginManager.registerPlugin(assessmentsOverviewPlugin);
|
||||||
pluginManager.registerPlugin(backgroundMusicPlugin);
|
pluginManager.registerPlugin(backgroundMusicPlugin);
|
||||||
|
//pluginManager.registerPlugin(messageFoldersPlugin);
|
||||||
//pluginManager.registerPlugin(testPlugin);
|
//pluginManager.registerPlugin(testPlugin);
|
||||||
|
|
||||||
// Register heavy plugins with lazy loading
|
// Register heavy plugins with lazy loading
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { analyzeHtmlThreats } from "./analyzeHtmlThreats";
|
||||||
|
|
||||||
|
describe("analyzeHtmlThreats", () => {
|
||||||
|
it("does not flag benign HTML", () => {
|
||||||
|
const r = analyzeHtmlThreats("<p>Hello <strong>world</strong></p>");
|
||||||
|
expect(r.blocked).toBe(false);
|
||||||
|
expect(r.findings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags script tags", () => {
|
||||||
|
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
|
||||||
|
expect(r.blocked).toBe(true);
|
||||||
|
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags javascript: URLs", () => {
|
||||||
|
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
|
||||||
|
expect(r.blocked).toBe(true);
|
||||||
|
expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags inline event handlers", () => {
|
||||||
|
const r = analyzeHtmlThreats('<img src="https://example.com/x.png" onerror="alert(1)">');
|
||||||
|
expect(r.blocked).toBe(true);
|
||||||
|
expect(
|
||||||
|
r.findings.some((f) => f.kind === "inline_event_handler"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows data:image/png sources", () => {
|
||||||
|
const r = analyzeHtmlThreats(
|
||||||
|
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
|
||||||
|
);
|
||||||
|
expect(r.blocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags data:text/html", () => {
|
||||||
|
const r = analyzeHtmlThreats(
|
||||||
|
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
|
||||||
|
);
|
||||||
|
expect(r.blocked).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
export interface ThreatFinding {
|
||||||
|
kind: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreatAnalysis {
|
||||||
|
blocked: boolean;
|
||||||
|
findings: ThreatFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INLINE_HANDLER_RE = /^on[a-z]+$/i;
|
||||||
|
|
||||||
|
const DANGEROUS_SCHEME_RE =
|
||||||
|
/^\s*(javascript|vbscript|about\s*:|file\s*:)/i;
|
||||||
|
|
||||||
|
/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */
|
||||||
|
function isDangerousDataUri(url: string): boolean {
|
||||||
|
const v = url.trim().toLowerCase();
|
||||||
|
if (!v.startsWith("data:")) return false;
|
||||||
|
if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Patterns inside executable contexts (script bodies). */
|
||||||
|
const SCRIPT_TEXT_SUSPICIOUS =
|
||||||
|
/\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i;
|
||||||
|
|
||||||
|
function addFinding(
|
||||||
|
findings: ThreatFinding[],
|
||||||
|
kind: string,
|
||||||
|
detail: string,
|
||||||
|
): void {
|
||||||
|
if (findings.some((f) => f.kind === kind && f.detail === detail)) return;
|
||||||
|
findings.push({ kind, detail });
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] {
|
||||||
|
const out: ThreatFinding[] = [];
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return out;
|
||||||
|
if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) {
|
||||||
|
out.push({
|
||||||
|
kind: "dangerous_url_scheme",
|
||||||
|
detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkElement(el: Element, findings: ThreatFinding[]): void {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (tag === "script") {
|
||||||
|
const src = el.getAttribute("src")?.trim() ?? "";
|
||||||
|
if (
|
||||||
|
src &&
|
||||||
|
(DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src))
|
||||||
|
) {
|
||||||
|
addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`);
|
||||||
|
} else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) {
|
||||||
|
addFinding(
|
||||||
|
findings,
|
||||||
|
"script_pattern",
|
||||||
|
"Inline script contains suspicious patterns (eval/new Function/document.write).",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addFinding(findings, "script_tag", "A script element is present in HTML.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === "meta") {
|
||||||
|
const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase();
|
||||||
|
if (httpEquiv === "refresh") {
|
||||||
|
addFinding(
|
||||||
|
findings,
|
||||||
|
"meta_refresh",
|
||||||
|
'meta http-equiv="refresh" can redirect or execute unexpectedly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === "iframe" || tag === "frame") {
|
||||||
|
const src = el.getAttribute("src")?.trim() ?? "";
|
||||||
|
const srcdoc = el.getAttribute("srcdoc") ?? "";
|
||||||
|
findings.push(...inspectUrlAttr("iframe[src]", src));
|
||||||
|
if (srcdoc.length > 0) {
|
||||||
|
addFinding(
|
||||||
|
findings,
|
||||||
|
"iframe_srcdoc",
|
||||||
|
"iframe srcdoc may embed arbitrary markup; nested analysis follows.",
|
||||||
|
);
|
||||||
|
nestedAnalyze(srcdoc, findings, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag === "object" || tag === "embed") {
|
||||||
|
const url =
|
||||||
|
el.getAttribute("data") ?? el.getAttribute("src") ?? "";
|
||||||
|
findings.push(...inspectUrlAttr(`${tag}[src/data]`, url));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
const name = attr.name;
|
||||||
|
if (INLINE_HANDLER_RE.test(name)) {
|
||||||
|
addFinding(
|
||||||
|
findings,
|
||||||
|
"inline_event_handler",
|
||||||
|
`${tag}.${name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const val = attr.value ?? "";
|
||||||
|
if (
|
||||||
|
name === "href" ||
|
||||||
|
name === "src" ||
|
||||||
|
name === "action" ||
|
||||||
|
name === "formaction" ||
|
||||||
|
name === "poster" ||
|
||||||
|
name === "data"
|
||||||
|
) {
|
||||||
|
findings.push(...inspectUrlAttr(`${tag}[${name}]`, val));
|
||||||
|
}
|
||||||
|
if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) {
|
||||||
|
addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(el.children)) {
|
||||||
|
walkElement(child, findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void {
|
||||||
|
if (depth <= 0) return;
|
||||||
|
let doc: Document;
|
||||||
|
try {
|
||||||
|
doc = new DOMParser().parseFromString(fragment, "text/html");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
walkElement(doc.documentElement, findings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-confidence HTML threat signals for user-generated / API HTML (messages, notices).
|
||||||
|
*
|
||||||
|
* Note: This runs after load for iframes in many cases; pairing with iframe `sandbox`
|
||||||
|
* (see BetterSEQTA Security plugin) is required for reliable script blocking — see plugin comments.
|
||||||
|
*/
|
||||||
|
export function analyzeHtmlThreats(html: string): ThreatAnalysis {
|
||||||
|
const findings: ThreatFinding[] = [];
|
||||||
|
|
||||||
|
if (!html || !html.trim()) {
|
||||||
|
return { blocked: false, findings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc: Document;
|
||||||
|
try {
|
||||||
|
doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
} catch {
|
||||||
|
return { blocked: false, findings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
walkElement(doc.documentElement, findings);
|
||||||
|
|
||||||
|
/** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */
|
||||||
|
const permissive = DOMPurify.sanitize(html, {
|
||||||
|
ADD_ATTR: ["onclick"],
|
||||||
|
ALLOWED_URI_REGEXP:
|
||||||
|
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
});
|
||||||
|
const strict = DOMPurify.sanitize(html, {
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
|
});
|
||||||
|
if (strict !== permissive) {
|
||||||
|
addFinding(
|
||||||
|
findings,
|
||||||
|
"dompurify_delta",
|
||||||
|
"Content was altered under strict sanitization (likely inline handlers or risky markup).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocked: findings.length > 0,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import type { ThreatAnalysis } from "./analyzeHtmlThreats";
|
||||||
|
import {
|
||||||
|
buildIncidentReport,
|
||||||
|
copyIncidentReport,
|
||||||
|
downloadIncidentReportPdf,
|
||||||
|
formatIncidentReportPlainText,
|
||||||
|
openIncidentReportEmail,
|
||||||
|
type IncidentReport,
|
||||||
|
} from "./incidentReport";
|
||||||
|
|
||||||
|
/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */
|
||||||
|
export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host";
|
||||||
|
/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */
|
||||||
|
export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay";
|
||||||
|
const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS;
|
||||||
|
const REPORT_LAYER_CLASS = "bss-security-report-layer";
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
style: Partial<CSSStyleDeclaration>,
|
||||||
|
text?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
Object.assign(node.style, style);
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(
|
||||||
|
label: string,
|
||||||
|
onClick: () => void,
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = label;
|
||||||
|
Object.assign(btn.style, {
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid rgba(255,255,255,0.25)",
|
||||||
|
background: "rgba(220,38,38,0.35)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "600",
|
||||||
|
transition: "background 0.2s, transform 0.2s",
|
||||||
|
} as CSSStyleDeclaration);
|
||||||
|
btn.addEventListener("mouseenter", () => {
|
||||||
|
btn.style.background = "rgba(220,38,38,0.55)";
|
||||||
|
});
|
||||||
|
btn.addEventListener("mouseleave", () => {
|
||||||
|
btn.style.background = "rgba(220,38,38,0.35)";
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExistingReportLayer(doc: Document): void {
|
||||||
|
doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MountBlockedContentOptions {
|
||||||
|
surface: "message" | "notice";
|
||||||
|
analysis: ThreatAnalysis;
|
||||||
|
rawSnippet?: string;
|
||||||
|
contextTitle?: string;
|
||||||
|
contextSubtitle?: string;
|
||||||
|
hostDocument?: Document;
|
||||||
|
/**
|
||||||
|
* Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size.
|
||||||
|
*/
|
||||||
|
rootOverlay?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountBlockedContentUi(
|
||||||
|
container: HTMLElement,
|
||||||
|
options: MountBlockedContentOptions,
|
||||||
|
): () => void {
|
||||||
|
const doc = options.hostDocument ?? document;
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.classList.add(HOST_CLASS);
|
||||||
|
const basePanel: Partial<CSSStyleDeclaration> = {
|
||||||
|
boxSizing: "border-box",
|
||||||
|
minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px",
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))",
|
||||||
|
border: "1px solid rgba(239,68,68,0.45)",
|
||||||
|
color: "#f4f4f5",
|
||||||
|
fontFamily: "system-ui, Segoe UI, Roboto, sans-serif",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
};
|
||||||
|
if (options.rootOverlay) {
|
||||||
|
Object.assign(container.style, {
|
||||||
|
...basePanel,
|
||||||
|
height: "100%",
|
||||||
|
overflow: "auto",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(container.style, {
|
||||||
|
...basePanel,
|
||||||
|
position: "relative",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = el("h2", {
|
||||||
|
margin: "0 0 12px 0",
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#fff",
|
||||||
|
}, "BetterSEQTA Security");
|
||||||
|
|
||||||
|
const lead = el("p", {
|
||||||
|
margin: "0 0 12px 0",
|
||||||
|
fontSize: "15px",
|
||||||
|
color: "#e4e4e7",
|
||||||
|
});
|
||||||
|
lead.textContent =
|
||||||
|
"This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting.";
|
||||||
|
|
||||||
|
const admin = el("p", {
|
||||||
|
margin: "0 0 20px 0",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#a1a1aa",
|
||||||
|
});
|
||||||
|
admin.textContent =
|
||||||
|
"Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source.";
|
||||||
|
|
||||||
|
const actions = el("div", {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "12px",
|
||||||
|
alignItems: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
let latestReport: IncidentReport | null = null;
|
||||||
|
|
||||||
|
const openReport = async () => {
|
||||||
|
latestReport = await buildIncidentReport({
|
||||||
|
surface: options.surface,
|
||||||
|
analysis: options.analysis,
|
||||||
|
rawSnippet: options.rawSnippet,
|
||||||
|
contextTitle: options.contextTitle,
|
||||||
|
contextSubtitle: options.contextSubtitle,
|
||||||
|
});
|
||||||
|
removeExistingReportLayer(doc);
|
||||||
|
const layer = doc.createElement("div");
|
||||||
|
layer.className = REPORT_LAYER_CLASS;
|
||||||
|
Object.assign(layer.style, {
|
||||||
|
position: "fixed",
|
||||||
|
inset: "0",
|
||||||
|
zIndex: "2147483647",
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "24px",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
});
|
||||||
|
|
||||||
|
const panel = doc.createElement("div");
|
||||||
|
Object.assign(panel.style, {
|
||||||
|
maxWidth: "640px",
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: "85vh",
|
||||||
|
overflow: "auto",
|
||||||
|
background: "#18181b",
|
||||||
|
color: "#fafafa",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid #3f3f46",
|
||||||
|
padding: "24px",
|
||||||
|
boxShadow: "0 25px 50px rgba(0,0,0,0.45)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const pre = doc.createElement("pre");
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordBreak = "break-word";
|
||||||
|
pre.style.fontSize = "12px";
|
||||||
|
pre.style.lineHeight = "1.45";
|
||||||
|
pre.style.margin = "0 0 16px 0";
|
||||||
|
pre.textContent = formatIncidentReportPlainText(latestReport);
|
||||||
|
|
||||||
|
const row = doc.createElement("div");
|
||||||
|
row.style.display = "flex";
|
||||||
|
row.style.flexWrap = "wrap";
|
||||||
|
row.style.gap = "10px";
|
||||||
|
|
||||||
|
const closeLayer = () => layer.remove();
|
||||||
|
|
||||||
|
row.appendChild(
|
||||||
|
button("Close", closeLayer),
|
||||||
|
);
|
||||||
|
row.appendChild(
|
||||||
|
button("Copy report", async () => {
|
||||||
|
if (!latestReport) return;
|
||||||
|
await copyIncidentReport(latestReport);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
row.appendChild(
|
||||||
|
button("Download PDF", () => {
|
||||||
|
if (!latestReport) return;
|
||||||
|
downloadIncidentReportPdf(latestReport);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
row.appendChild(
|
||||||
|
button("Email", () => {
|
||||||
|
if (!latestReport) return;
|
||||||
|
openIncidentReportEmail(latestReport);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
panel.appendChild(pre);
|
||||||
|
panel.appendChild(row);
|
||||||
|
layer.appendChild(panel);
|
||||||
|
layer.addEventListener("click", (e) => {
|
||||||
|
if (e.target === layer) closeLayer();
|
||||||
|
});
|
||||||
|
doc.body.appendChild(layer);
|
||||||
|
};
|
||||||
|
|
||||||
|
actions.appendChild(button("View report", () => void openReport()));
|
||||||
|
|
||||||
|
container.appendChild(title);
|
||||||
|
container.appendChild(lead);
|
||||||
|
container.appendChild(admin);
|
||||||
|
container.appendChild(actions);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.classList.remove(HOST_CLASS);
|
||||||
|
removeExistingReportLayer(doc);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { jsPDF } from "jspdf";
|
||||||
|
import browser from "webextension-polyfill";
|
||||||
|
|
||||||
|
import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats";
|
||||||
|
|
||||||
|
export type ThreatSurface = "message" | "notice";
|
||||||
|
|
||||||
|
export interface IncidentReport {
|
||||||
|
generatedAtIso: string;
|
||||||
|
surface: ThreatSurface;
|
||||||
|
extensionVersion: string;
|
||||||
|
pageUrl: string;
|
||||||
|
contextTitle?: string;
|
||||||
|
contextSubtitle?: string;
|
||||||
|
analysis: ThreatAnalysis;
|
||||||
|
contentFingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(text: string): Promise<string> {
|
||||||
|
const enc = new TextEncoder().encode(text);
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", enc);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtensionVersion(): string {
|
||||||
|
try {
|
||||||
|
return browser.runtime?.getManifest?.()?.version ?? "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildIncidentReport(opts: {
|
||||||
|
surface: ThreatSurface;
|
||||||
|
analysis: ThreatAnalysis;
|
||||||
|
rawSnippet?: string;
|
||||||
|
contextTitle?: string;
|
||||||
|
contextSubtitle?: string;
|
||||||
|
extensionVersion?: string;
|
||||||
|
}): Promise<IncidentReport> {
|
||||||
|
let fingerprint: string | undefined;
|
||||||
|
if (opts.rawSnippet?.trim()) {
|
||||||
|
try {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.subtle) {
|
||||||
|
fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fingerprint = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = opts.extensionVersion ?? getExtensionVersion();
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAtIso: new Date().toISOString(),
|
||||||
|
surface: opts.surface,
|
||||||
|
extensionVersion: version,
|
||||||
|
pageUrl:
|
||||||
|
typeof window !== "undefined" ? window.location.href : "",
|
||||||
|
contextTitle: opts.contextTitle,
|
||||||
|
contextSubtitle: opts.contextSubtitle,
|
||||||
|
analysis: opts.analysis,
|
||||||
|
contentFingerprint: fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFindings(findings: ThreatFinding[]): string {
|
||||||
|
return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIncidentReportPlainText(report: IncidentReport): string {
|
||||||
|
const lines = [
|
||||||
|
"BetterSEQTA+ Security — incident report",
|
||||||
|
"=====================================",
|
||||||
|
"",
|
||||||
|
`Generated (UTC): ${report.generatedAtIso}`,
|
||||||
|
`Surface: ${report.surface}`,
|
||||||
|
`Extension version: ${report.extensionVersion}`,
|
||||||
|
`Page URL: ${report.pageUrl}`,
|
||||||
|
];
|
||||||
|
if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`);
|
||||||
|
if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`);
|
||||||
|
if (report.contentFingerprint) {
|
||||||
|
lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
|
||||||
|
}
|
||||||
|
lines.push("", "Findings:", formatFindings(report.analysis.findings), "");
|
||||||
|
lines.push(
|
||||||
|
"Next steps:",
|
||||||
|
"- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.",
|
||||||
|
"- Attach this report (PDF or pasted text) when reporting.",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyIncidentReport(report: IncidentReport): Promise<void> {
|
||||||
|
const text = formatIncidentReportPlainText(report);
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadIncidentReportPdf(report: IncidentReport): void {
|
||||||
|
const doc = new jsPDF({ unit: "pt", format: "a4" });
|
||||||
|
const margin = 48;
|
||||||
|
let y = margin;
|
||||||
|
const lineHeight = 14;
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight();
|
||||||
|
const maxWidth = doc.internal.pageSize.getWidth() - margin * 2;
|
||||||
|
|
||||||
|
const pushLines = (text: string, bold = false) => {
|
||||||
|
doc.setFont("helvetica", bold ? "bold" : "normal");
|
||||||
|
const wrapped = doc.splitTextToSize(text, maxWidth) as string[];
|
||||||
|
for (const line of wrapped) {
|
||||||
|
if (y > pageHeight - margin) {
|
||||||
|
doc.addPage();
|
||||||
|
y = margin;
|
||||||
|
}
|
||||||
|
doc.text(line, margin, y);
|
||||||
|
y += lineHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pushLines("BetterSEQTA+ Security — incident report", true);
|
||||||
|
pushLines(`Generated (UTC): ${report.generatedAtIso}`);
|
||||||
|
pushLines(`Surface: ${report.surface}`);
|
||||||
|
pushLines(`Extension version: ${report.extensionVersion}`);
|
||||||
|
pushLines(`Page URL: ${report.pageUrl}`);
|
||||||
|
if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`);
|
||||||
|
if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`);
|
||||||
|
if (report.contentFingerprint) {
|
||||||
|
pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`);
|
||||||
|
}
|
||||||
|
pushLines("");
|
||||||
|
pushLines("Findings:", true);
|
||||||
|
for (const f of report.analysis.findings) {
|
||||||
|
pushLines(`• [${f.kind}] ${f.detail}`);
|
||||||
|
}
|
||||||
|
pushLines("");
|
||||||
|
pushLines("Next steps:", true);
|
||||||
|
pushLines(
|
||||||
|
"Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.",
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openIncidentReportEmail(report: IncidentReport): void {
|
||||||
|
const subject = encodeURIComponent(
|
||||||
|
"SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security",
|
||||||
|
);
|
||||||
|
const body = encodeURIComponent(
|
||||||
|
formatIncidentReportPlainText(report).slice(0, 1800) +
|
||||||
|
"\n\n[If truncated: use Copy in the report dialog for the full text.]",
|
||||||
|
);
|
||||||
|
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
|||||||
import ColorLuminance from "./ColorLuminance";
|
import ColorLuminance from "./ColorLuminance";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||||
import { getCustomThemeAdaptiveCssVariables } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||||
|
|
||||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||||
@@ -84,6 +84,21 @@ function cancelColorTransition() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRepresentativeRgbChannels(s: string): { r: number; g: number; b: number } | null {
|
||||||
|
const parsedHex = parseRepresentativeHex(s);
|
||||||
|
if (!parsedHex) return null;
|
||||||
|
try {
|
||||||
|
const [r, g, b] = Color(parsedHex).rgb().array();
|
||||||
|
return {
|
||||||
|
r: Math.round(r),
|
||||||
|
g: Math.round(g),
|
||||||
|
b: Math.round(b),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyColorsWith(selectedColor: string) {
|
function applyColorsWith(selectedColor: string) {
|
||||||
if (settingsState.transparencyEffects) {
|
if (settingsState.transparencyEffects) {
|
||||||
document.documentElement.classList.add("transparencyEffects");
|
document.documentElement.classList.add("transparencyEffects");
|
||||||
@@ -129,8 +144,35 @@ function applyColorsWith(selectedColor: string) {
|
|||||||
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
|
applyProperties({ ...commonProps, ...modeProps, ...dynamicProps });
|
||||||
|
|
||||||
if (settingsState.selectedTheme) {
|
if (settingsState.selectedTheme) {
|
||||||
for (const name of getCustomThemeAdaptiveCssVariables()) {
|
const channels = getRepresentativeRgbChannels(selectedColor);
|
||||||
setCSSVar(name, selectedColor);
|
for (const binding of getCustomThemeAdaptiveCssVariableBindings()) {
|
||||||
|
if (!binding.channel) {
|
||||||
|
setCSSVar(binding.cssVarName, selectedColor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channels) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.channel === "r") {
|
||||||
|
setCSSVar(binding.cssVarName, String(channels.r));
|
||||||
|
} else if (binding.channel === "g") {
|
||||||
|
setCSSVar(binding.cssVarName, String(channels.g));
|
||||||
|
} else {
|
||||||
|
setCSSVar(binding.cssVarName, String(channels.b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let themes opt-in to overriding only adaptive accent output.
|
||||||
|
// A theme can define `--adaptive-better-main` from adaptive channel bindings.
|
||||||
|
if (settingsState.selectedTheme && settingsState.adaptiveThemeColour) {
|
||||||
|
const adaptiveOverride = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--adaptive-better-main")
|
||||||
|
.trim();
|
||||||
|
if (adaptiveOverride) {
|
||||||
|
setCSSVar("--better-main", adaptiveOverride);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,49 @@
|
|||||||
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
|
/** Tracks which author-declared CSS variables mirror the effective accent; not persisted in settings storage. */
|
||||||
|
|
||||||
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
|
const VALID_CUSTOM_PROP = /^--[a-zA-Z0-9_-]{1,120}$/;
|
||||||
|
const VALID_CHANNEL = /^(r|g|b)$/;
|
||||||
|
|
||||||
let boundNames: string[] = [];
|
export type AdaptiveChannel = "r" | "g" | "b";
|
||||||
|
|
||||||
export function normalizeAdaptiveCssVariableNames(
|
export type AdaptiveCssVariableBinding = {
|
||||||
|
cssVarName: string;
|
||||||
|
channel?: AdaptiveChannel;
|
||||||
|
};
|
||||||
|
|
||||||
|
let boundBindings: AdaptiveCssVariableBinding[] = [];
|
||||||
|
|
||||||
|
function parseAdaptiveBinding(
|
||||||
|
rawBinding: string,
|
||||||
|
): AdaptiveCssVariableBinding | null {
|
||||||
|
const trimmed = rawBinding.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const [rawName, rawChannel] = trimmed.split(":", 2);
|
||||||
|
const cssVarName = rawName?.trim() ?? "";
|
||||||
|
if (!VALID_CUSTOM_PROP.test(cssVarName)) return null;
|
||||||
|
|
||||||
|
if (!rawChannel) return { cssVarName };
|
||||||
|
|
||||||
|
const channel = rawChannel.trim().toLowerCase();
|
||||||
|
if (!VALID_CHANNEL.test(channel)) return null;
|
||||||
|
|
||||||
|
return { cssVarName, channel: channel as AdaptiveChannel };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAdaptiveCssVariableBindings(
|
||||||
names: string[] | undefined,
|
names: string[] | undefined,
|
||||||
): string[] {
|
): AdaptiveCssVariableBinding[] {
|
||||||
if (!names?.length) return [];
|
if (!names?.length) return [];
|
||||||
const out: string[] = [];
|
const out: AdaptiveCssVariableBinding[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const raw of names) {
|
for (const raw of names) {
|
||||||
const s = raw.trim();
|
const parsed = parseAdaptiveBinding(raw);
|
||||||
if (!VALID_CUSTOM_PROP.test(s) || seen.has(s)) continue;
|
if (!parsed) continue;
|
||||||
seen.add(s);
|
const key = `${parsed.cssVarName}:${parsed.channel ?? "full"}`;
|
||||||
out.push(s);
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(parsed);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -22,19 +51,24 @@ export function normalizeAdaptiveCssVariableNames(
|
|||||||
export function setCustomThemeAdaptiveCssVariables(
|
export function setCustomThemeAdaptiveCssVariables(
|
||||||
names: string[] | undefined,
|
names: string[] | undefined,
|
||||||
): void {
|
): void {
|
||||||
for (const n of boundNames) {
|
for (const binding of boundBindings) {
|
||||||
document.documentElement.style.removeProperty(n);
|
document.documentElement.style.removeProperty(binding.cssVarName);
|
||||||
}
|
}
|
||||||
boundNames = normalizeAdaptiveCssVariableNames(names);
|
boundBindings = normalizeAdaptiveCssVariableBindings(names);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCustomThemeAdaptiveCssVariableBindings(): AdaptiveCssVariableBinding[] {
|
||||||
|
return boundBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible helper for existing callsites.
|
||||||
export function getCustomThemeAdaptiveCssVariables(): string[] {
|
export function getCustomThemeAdaptiveCssVariables(): string[] {
|
||||||
return boundNames;
|
return boundBindings.map((b) => b.cssVarName);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearCustomThemeAdaptiveCssVariables(): void {
|
export function clearCustomThemeAdaptiveCssVariables(): void {
|
||||||
for (const n of boundNames) {
|
for (const binding of boundBindings) {
|
||||||
document.documentElement.style.removeProperty(n);
|
document.documentElement.style.removeProperty(binding.cssVarName);
|
||||||
}
|
}
|
||||||
boundNames = [];
|
boundBindings = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
toISODate,
|
toISODate,
|
||||||
weekRangeContaining,
|
weekRangeContaining,
|
||||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||||
|
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||||
|
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||||
|
|
||||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||||
const home = document.getElementById("homebutton");
|
const home = document.getElementById("homebutton");
|
||||||
@@ -356,6 +358,12 @@ function openEngageNoticeModal(
|
|||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
.replace(/ +/, " ");
|
.replace(/ +/, " ");
|
||||||
|
|
||||||
|
const threatAnalysis = analyzeHtmlThreats(cleanContent);
|
||||||
|
const noticeBlocked = threatAnalysis.blocked;
|
||||||
|
const noticeBodyInner = noticeBlocked
|
||||||
|
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||||
|
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||||
|
|
||||||
document.getElementById("notice-modal")?.remove();
|
document.getElementById("notice-modal")?.remove();
|
||||||
|
|
||||||
const sourceRect = sourceElement.getBoundingClientRect();
|
const sourceRect = sourceElement.getBoundingClientRect();
|
||||||
@@ -389,7 +397,7 @@ function openEngageNoticeModal(
|
|||||||
<button class="notice-close-btn">×</button>
|
<button class="notice-close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
${noticeBodyInner}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +414,23 @@ function openEngageNoticeModal(
|
|||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
if (noticeBlocked) {
|
||||||
|
const mountEl = modal.querySelector(
|
||||||
|
".bss-security-notice-mount",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (mountEl) {
|
||||||
|
mountBlockedContentUi(mountEl, {
|
||||||
|
surface: "notice",
|
||||||
|
analysis: threatAnalysis,
|
||||||
|
rawSnippet: cleanContent.slice(0, 50_000),
|
||||||
|
contextTitle: String(notice.title ?? ""),
|
||||||
|
contextSubtitle: [notice.staff, notice.label_title]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceElement.setAttribute("data-transitioning", "true");
|
sourceElement.setAttribute("data-transitioning", "true");
|
||||||
sourceElement.style.opacity = "0";
|
sourceElement.style.opacity = "0";
|
||||||
sourceElement.style.transform = "scale(0.95)";
|
sourceElement.style.transform = "scale(0.95)";
|
||||||
@@ -431,7 +456,11 @@ function openEngageNoticeModal(
|
|||||||
<button class="notice-close-btn">×</button>
|
<button class="notice-close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
${
|
||||||
|
noticeBlocked
|
||||||
|
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||||
|
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(tempMeasureDiv);
|
document.body.appendChild(tempMeasureDiv);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
|||||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||||
|
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||||
|
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||||
|
|
||||||
let LessonInterval: any;
|
let LessonInterval: any;
|
||||||
let currentSelectedDate = new Date();
|
let currentSelectedDate = new Date();
|
||||||
@@ -384,6 +386,12 @@ function openNoticeModal(
|
|||||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||||
.replace(/ +/, " ");
|
.replace(/ +/, " ");
|
||||||
|
|
||||||
|
const threatAnalysis = analyzeHtmlThreats(cleanContent);
|
||||||
|
const noticeBlocked = threatAnalysis.blocked;
|
||||||
|
const noticeBodyInner = noticeBlocked
|
||||||
|
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||||
|
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||||
|
|
||||||
document.getElementById("notice-modal")?.remove();
|
document.getElementById("notice-modal")?.remove();
|
||||||
|
|
||||||
const sourceRect = sourceElement.getBoundingClientRect();
|
const sourceRect = sourceElement.getBoundingClientRect();
|
||||||
@@ -417,7 +425,7 @@ function openNoticeModal(
|
|||||||
<button class="notice-close-btn">×</button>
|
<button class="notice-close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
${noticeBodyInner}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +442,23 @@ function openNoticeModal(
|
|||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
if (noticeBlocked) {
|
||||||
|
const mountEl = modal.querySelector(
|
||||||
|
".bss-security-notice-mount",
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (mountEl) {
|
||||||
|
mountBlockedContentUi(mountEl, {
|
||||||
|
surface: "notice",
|
||||||
|
analysis: threatAnalysis,
|
||||||
|
rawSnippet: cleanContent.slice(0, 50_000),
|
||||||
|
contextTitle: String(notice.title ?? ""),
|
||||||
|
contextSubtitle: [notice.staff, notice.label_title]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sourceElement.setAttribute("data-transitioning", "true");
|
sourceElement.setAttribute("data-transitioning", "true");
|
||||||
sourceElement.style.opacity = "0";
|
sourceElement.style.opacity = "0";
|
||||||
sourceElement.style.transform = "scale(0.95)";
|
sourceElement.style.transform = "scale(0.95)";
|
||||||
@@ -459,7 +484,11 @@ function openNoticeModal(
|
|||||||
<button class="notice-close-btn">×</button>
|
<button class="notice-close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="notice-content-title">${notice.title}</h2>
|
<h2 class="notice-content-title">${notice.title}</h2>
|
||||||
<div class="notice-content-body">${cleanContent}</div>
|
${
|
||||||
|
noticeBlocked
|
||||||
|
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||||
|
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(tempMeasureDiv);
|
document.body.appendChild(tempMeasureDiv);
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
const text = stringToHTML(/* html */ `
|
const text = stringToHTML(/* html */ `
|
||||||
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
|
||||||
|
|
||||||
|
<h1>3.6.5 - Assessment weighting override & fixes</h1>
|
||||||
|
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
|
||||||
|
<li>Fixed the display of weightings that could not automatically be discovered.</li>
|
||||||
|
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
|
||||||
|
|
||||||
|
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
|
||||||
|
<li>Added advanced colour adjustments variables for theme customisation.</li>
|
||||||
|
<li>Improved logic for upcoming assements dashlet to improve compatibility.</li>
|
||||||
|
<li>BS Cloud can now automatically download themes from other devices.</li>
|
||||||
|
<li>Added theme flavours for multiple colour variations of the same theme.</li>
|
||||||
|
|
||||||
<h1>3.6.3 - Assessment overview fix</h1>
|
<h1>3.6.3 - Assessment overview fix</h1>
|
||||||
<li>Fixed assessments overview failing to load.</li>
|
<li>Fixed assessments overview failing to load.</li>
|
||||||
|
|
||||||
@@ -50,6 +61,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
|||||||
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
|
<li>Updated outdated in-app links and update some under the hood code (Vite 8).</li>
|
||||||
<li>Added a notifications panel animation to work like settings.</li>
|
<li>Added a notifications panel animation to work like settings.</li>
|
||||||
<li>Fix timetable edit plugin not working correctly.</li>
|
<li>Fix timetable edit plugin not working correctly.</li>
|
||||||
|
|
||||||
<h1>3.5.3 - Adaptive theme updates</h1>
|
<h1>3.5.3 - Adaptive theme updates</h1>
|
||||||
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
<li>Fixed adaptive theming on current-year course and assessment pages.</li>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export const CLOUD_SETTINGS_SYNC_SCHEMA_VERSION = 1;
|
|||||||
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
|
export const BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY =
|
||||||
"bsplus_cloud_settings_known_remote_updated_at";
|
"bsplus_cloud_settings_known_remote_updated_at";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Written by the service worker after applying a cloud settings envelope; the SEQTA page’s
|
||||||
|
* ThemeManager reads and clears it (SW cannot share localforage/IndexedDB with the page).
|
||||||
|
*/
|
||||||
|
export const BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY =
|
||||||
|
"bsplus_pending_theme_ensure_after_cloud";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Never uploaded to the cloud backup (OAuth and legacy keys).
|
* Never uploaded to the cloud backup (OAuth and legacy keys).
|
||||||
* IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of
|
* IndexedDB (e.g. Global Search’s `betterseqta-index` database) is not part of
|
||||||
@@ -39,6 +46,7 @@ export const SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES = ["plugin.global-search.stor
|
|||||||
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
|
const CLIENT_ONLY_CLOUD_KEYS_EXACT = [
|
||||||
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY,
|
||||||
"bsplus_lastCloudPoll",
|
"bsplus_lastCloudPoll",
|
||||||
|
BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** After restoring from cloud, keep local session so the user stays signed in. */
|
/** After restoring from cloud, keep local session so the user stays signed in. */
|
||||||
@@ -101,8 +109,15 @@ function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Recor
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stored theme id (`selectedTheme`); trims whitespace; empty string clears. */
|
||||||
|
export function normalizeThemeIdForSync(raw: unknown): string {
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildUploadPayload(all: Record<string, unknown>): {
|
export function buildUploadPayload(all: Record<string, unknown>): {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
themeId: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
const filtered: Record<string, unknown> = {};
|
const filtered: Record<string, unknown> = {};
|
||||||
@@ -111,17 +126,57 @@ export function buildUploadPayload(all: Record<string, unknown>): {
|
|||||||
filtered[k] = v;
|
filtered[k] = v;
|
||||||
}
|
}
|
||||||
const data = migrateLegacyToPluginSettings(filtered);
|
const data = migrateLegacyToPluginSettings(filtered);
|
||||||
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data };
|
const themeId = normalizeThemeIdForSync(all.selectedTheme);
|
||||||
|
return {
|
||||||
|
schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION,
|
||||||
|
themeId,
|
||||||
|
data,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSnapshotForUpload(): Promise<{
|
export async function getSnapshotForUpload(): Promise<{
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
|
themeId: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}> {
|
}> {
|
||||||
const all = await browser.storage.local.get();
|
const all = await browser.storage.local.get();
|
||||||
return buildUploadPayload(all as Record<string, unknown>);
|
return buildUploadPayload(all as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme to ensure is installed locally after a downloaded envelope (explicit `themeId` overrides `data.selectedTheme`).
|
||||||
|
* Works for any store-backed id, including **flavour (slave) variants** nested under masters in the catalogue.
|
||||||
|
*/
|
||||||
|
export function resolveThemeIdForPostSyncDownload(envelope: unknown): string | undefined {
|
||||||
|
if (envelope && typeof envelope === "object" && "themeId" in envelope) {
|
||||||
|
const top = normalizeThemeIdForSync(
|
||||||
|
(envelope as Record<string, unknown>).themeId,
|
||||||
|
);
|
||||||
|
if (top) return top;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteFlat: Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
envelope &&
|
||||||
|
typeof envelope === "object" &&
|
||||||
|
"data" in envelope &&
|
||||||
|
(envelope as { data?: unknown }).data !== undefined &&
|
||||||
|
typeof (envelope as { data?: unknown }).data === "object" &&
|
||||||
|
(envelope as { data?: unknown }).data !== null &&
|
||||||
|
!Array.isArray((envelope as { data?: unknown }).data)
|
||||||
|
) {
|
||||||
|
remoteFlat = (envelope as { data: Record<string, unknown> }).data;
|
||||||
|
} else if (envelope && typeof envelope === "object" && !Array.isArray(envelope)) {
|
||||||
|
remoteFlat = envelope as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrated = migrateLegacyToPluginSettings(remoteFlat);
|
||||||
|
const fromData = normalizeThemeIdForSync(migrated.selectedTheme);
|
||||||
|
return fromData === "" ? undefined : fromData;
|
||||||
|
}
|
||||||
|
|
||||||
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
|
export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise<void> {
|
||||||
if (!iso || typeof iso !== "string") return;
|
if (!iso || typeof iso !== "string") return;
|
||||||
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
|
await browser.storage.local.set({ [BSPLUS_CLOUD_KNOWN_REMOTE_UPDATED_AT_KEY]: iso });
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user