mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa1e1a925e |
@@ -10,6 +10,11 @@ bun.lock
|
||||
src/public/resources/pdfjs/pdf.worker.min.mjs
|
||||
src/public/resources/pdfjs/pdf.legacy.min.mjs
|
||||
|
||||
# Rust / wasm
|
||||
/target/
|
||||
# wasm-pack output (regenerate with `npm run build:wasm`)
|
||||
src/wasm/pkg/
|
||||
|
||||
# Build
|
||||
extension.zip
|
||||
build/
|
||||
|
||||
Generated
+246
@@ -0,0 +1,246 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "betterseqta-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"csscolorparser",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "csscolorparser"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16"
|
||||
dependencies = [
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wasm/betterseqta-wasm"]
|
||||
+8
-7
@@ -6,6 +6,12 @@
|
||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
|
||||
"build:wasm": "npx --yes wasm-pack@0.13.1 build wasm/betterseqta-wasm --target web --out-dir ../../src/wasm/pkg --release && node scripts/rm-wasm-pkg-gitignore.mjs",
|
||||
"prebuild": "npm run build:wasm",
|
||||
"prebuild:chrome": "npm run build:wasm",
|
||||
"prebuild:firefox": "npm run build:wasm",
|
||||
"prebuild:safari": "npm run build:wasm",
|
||||
"prebuild:dev": "npm run build:wasm",
|
||||
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||
"dev": "cross-env MODE=chrome vite dev",
|
||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||
@@ -19,8 +25,7 @@
|
||||
"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",
|
||||
"publish": "bun lib/publish.js --b",
|
||||
"zip": "bedframe zip",
|
||||
"test": "vitest run"
|
||||
"zip": "bedframe zip"
|
||||
},
|
||||
"targets": {
|
||||
"prod": {
|
||||
@@ -41,7 +46,6 @@
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@bedframe/cli": "^0.1.2",
|
||||
"@crxjs/vite-plugin": "^2.4.0",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
@@ -49,7 +53,6 @@
|
||||
"dependency-cruiser": "^17.0.1",
|
||||
"eslint": "^9.33.0",
|
||||
"glob": "^11.0.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"mime-types": "^3.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"process": "^0.11.10",
|
||||
@@ -58,8 +61,7 @@
|
||||
"sass-loader": "^16.0.5",
|
||||
"semver": "^7.7.1",
|
||||
"tailwindcss": "3",
|
||||
"url": "^0.11.4",
|
||||
"vitest": "^4.1.5"
|
||||
"url": "^0.11.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bedframe/core": "^0.1.0",
|
||||
@@ -96,7 +98,6 @@
|
||||
"flexsearch": "^0.8.147",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"localforage": "^1.10.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mathjs": "^14.4.0",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
||||
try {
|
||||
unlinkSync(join(root, "src", "wasm", "pkg", ".gitignore"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
+58
-12
@@ -10,6 +10,12 @@ import * as plugins from "@/plugins";
|
||||
import { main } from "@/seqta/main";
|
||||
import { delay } from "./seqta/utils/delay";
|
||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||
import {
|
||||
childTextHasSeqtaCopyright,
|
||||
initBetterseqtaWasm,
|
||||
isBetterseqtaWasmReady,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
} from "@/wasm/init";
|
||||
|
||||
function registerFetchSeqtaAppLinkListener() {
|
||||
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||
@@ -35,27 +41,67 @@ function registerFetchSeqtaAppLinkListener() {
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleDeferredPluginInitialization() {
|
||||
const start = () => {
|
||||
void plugins.initializePlugins().catch((error) => {
|
||||
console.error("[BetterSEQTA+] Deferred plugin initialization failed:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||
const idle = window.requestIdleCallback as (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions,
|
||||
) => number;
|
||||
idle(() => start(), { timeout: 1200 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for browsers without requestIdleCallback.
|
||||
setTimeout(start, 0);
|
||||
}
|
||||
|
||||
export let MenuOptionsOpen = false;
|
||||
|
||||
var IsSEQTAPage = false;
|
||||
let hasSEQTAText = false;
|
||||
|
||||
function childTextLooksLikeSeqtaCopyright(text: string): boolean {
|
||||
return text.includes("Copyright (c) SEQTA Software");
|
||||
}
|
||||
|
||||
function titleLooksLikeSeqtaLearnOrEngage(title: string): boolean {
|
||||
return title.includes("SEQTA Learn") || title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
// This check is placed outside of the document load event due to issues with EP (https://github.com/BetterSEQTA/BetterSEQTA-Plus/issues/84)
|
||||
if (document.childNodes[1]) {
|
||||
void bootstrap();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await initBetterseqtaWasm();
|
||||
} catch (e) {
|
||||
console.warn("[BetterSEQTA+] WASM init failed, using JS fallbacks:", e);
|
||||
}
|
||||
|
||||
const childText = document.childNodes[1]?.textContent;
|
||||
hasSEQTAText =
|
||||
document.childNodes[1].textContent?.includes(
|
||||
"Copyright (c) SEQTA Software",
|
||||
) ?? false;
|
||||
init();
|
||||
typeof childText === "string" &&
|
||||
(isBetterseqtaWasmReady()
|
||||
? childTextHasSeqtaCopyright(childText)
|
||||
: childTextLooksLikeSeqtaCopyright(childText));
|
||||
|
||||
await init();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (
|
||||
hasSEQTAText &&
|
||||
(document.title.includes("SEQTA Learn") ||
|
||||
document.title.includes("SEQTA Engage")) &&
|
||||
!IsSEQTAPage
|
||||
) {
|
||||
const titleOk = isBetterseqtaWasmReady()
|
||||
? titleIsSeqtaLearnOrEngage(document.title)
|
||||
: titleLooksLikeSeqtaLearnOrEngage(document.title);
|
||||
|
||||
if (hasSEQTAText && titleOk && !IsSEQTAPage) {
|
||||
IsSEQTAPage = true;
|
||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||
|
||||
@@ -107,7 +153,7 @@ async function init() {
|
||||
plugins.Monofile();
|
||||
|
||||
if (settingsState.onoff) {
|
||||
await plugins.initializePlugins();
|
||||
scheduleDeferredPluginInitialization();
|
||||
}
|
||||
|
||||
if (settingsState.devMode) {
|
||||
@@ -131,4 +177,4 @@ function replaceIcons() {
|
||||
link.href = icon48;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"service_worker": "background.ts"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' http: https: https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com https://newsapi.org"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
@@ -33,6 +33,7 @@
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": [
|
||||
"assets/*",
|
||||
"resources/icons/*",
|
||||
"resources/update-image.webp",
|
||||
"resources/pdfjs/pdf.worker.min.mjs",
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
import { type Plugin } from "@/plugins/core/types";
|
||||
import stringToHTML from "@/seqta/utils/stringToHTML";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
|
||||
import {
|
||||
clearStuck,
|
||||
getClassByPattern,
|
||||
initStorage,
|
||||
injectWeightingsTab,
|
||||
letterToNumber,
|
||||
parseAssessments,
|
||||
processAssessments,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, string>;
|
||||
assessments: Record<string, string>;
|
||||
weightingOverrides: Record<string, string>;
|
||||
}
|
||||
|
||||
const settings = defineSettings({
|
||||
@@ -38,8 +37,6 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
||||
|
||||
const instance = new AssessmentsAveragePluginClass();
|
||||
|
||||
let overrideListenerController: AbortController | null = null;
|
||||
|
||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
id: "assessments-average",
|
||||
name: "Assessment Averages",
|
||||
@@ -61,149 +58,143 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
);
|
||||
|
||||
await parseAssessments(api);
|
||||
await renderSubjectAverage(api);
|
||||
overrideListenerController?.abort();
|
||||
overrideListenerController = new AbortController();
|
||||
document.addEventListener(
|
||||
"betterseqta:overrideChanged",
|
||||
() => renderSubjectAverage(api),
|
||||
{ signal: overrideListenerController.signal },
|
||||
|
||||
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 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");
|
||||
if (wrapper) {
|
||||
const observer = new MutationObserver(() => {
|
||||
applySubjectColourToOverallResult();
|
||||
});
|
||||
observer.observe(wrapper, { childList: true, subtree: true });
|
||||
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() {
|
||||
const selectedAssessmentItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||
|
||||
@@ -5,9 +5,57 @@ import {
|
||||
getPdfjsPageContextUrls,
|
||||
} from "@/lib/pdfjsExtension.ts";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import {
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extractWeightFromPdfText,
|
||||
isBetterseqtaWasmReady,
|
||||
isFirefoxUserAgent,
|
||||
parseGradeToPercent,
|
||||
} from "@/wasm/init";
|
||||
|
||||
ensurePdfjsWorker();
|
||||
|
||||
function escJsSingleQuoted(s: string): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return escapeJsSingleQuotedString(s);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escJsInlineUrl(s: string): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return escapeJsForInlineScript(s);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function detectFirefox(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return isFirefoxUserAgent(ua);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const u = ua.toLowerCase();
|
||||
return (
|
||||
u.indexOf("firefox") > -1 &&
|
||||
!u.includes("seamonkey") &&
|
||||
!u.includes("waterfox")
|
||||
);
|
||||
}
|
||||
|
||||
export async function initStorage(api: any) {
|
||||
await api.storage.loaded;
|
||||
|
||||
@@ -17,9 +65,6 @@ export async function initStorage(api: any) {
|
||||
if (!api.storage.assessments) {
|
||||
api.storage.assessments = {};
|
||||
}
|
||||
if (!api.storage.weightingOverrides) {
|
||||
api.storage.weightingOverrides = {};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStuck(api: any) {
|
||||
@@ -66,7 +111,7 @@ export const letterToNumber: Record<string, number> = {
|
||||
F: 0,
|
||||
};
|
||||
|
||||
function parseGrade(text: string): number {
|
||||
function parseGradeTs(text: string): number {
|
||||
const str = text.trim().toUpperCase();
|
||||
if (str.includes("/")) {
|
||||
const [raw, max] = str.split("/").map((n) => parseFloat(n));
|
||||
@@ -78,104 +123,78 @@ function parseGrade(text: string): number {
|
||||
return letterToNumber[str] ?? 0;
|
||||
}
|
||||
|
||||
function parseGrade(text: string): number {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return parseGradeToPercent(text);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return parseGradeTs(text);
|
||||
}
|
||||
|
||||
function createWeightLabel(
|
||||
assessmentItem: Element,
|
||||
weighting: string | undefined,
|
||||
) {
|
||||
let statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||
) as HTMLElement | null;
|
||||
const statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___']`,
|
||||
) as HTMLElement;
|
||||
|
||||
if (!statsContainer) {
|
||||
const statsClass = getClassByPattern(document, "AssessmentItem__stats___");
|
||||
statsContainer = document.createElement("div");
|
||||
statsContainer.className = statsClass;
|
||||
statsContainer.classList.add("betterseqta-stats-container");
|
||||
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
|
||||
if (thermoscore) {
|
||||
thermoscore.insertAdjacentElement("afterend", statsContainer);
|
||||
} else {
|
||||
assessmentItem.appendChild(statsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
const hasNativeLabel = !!statsContainer.querySelector(
|
||||
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
|
||||
);
|
||||
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;
|
||||
if (
|
||||
!statsContainer ||
|
||||
statsContainer.querySelector(".betterseqta-weight-label")
|
||||
)
|
||||
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(
|
||||
const label = statsContainer.querySelector(
|
||||
`[class*='Label__Label___']`,
|
||||
) as HTMLElement | null;
|
||||
) as HTMLElement;
|
||||
|
||||
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;
|
||||
})();
|
||||
if (!label) return;
|
||||
|
||||
const weightLabel = label.cloneNode(true) as HTMLElement;
|
||||
weightLabel.classList.add("betterseqta-weight-label");
|
||||
weightLabel.style.flex = "none";
|
||||
weightLabel.style.width = "fit-content";
|
||||
|
||||
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`);
|
||||
const innerTextDiv = weightLabel.querySelector(
|
||||
`[class*='Label__innerText___']`,
|
||||
);
|
||||
if (innerTextDiv) innerTextDiv.textContent = "Weight";
|
||||
|
||||
const textNodes = Array.from(weightLabel.childNodes).filter(
|
||||
(node) => node.nodeType === Node.TEXT_NODE,
|
||||
);
|
||||
|
||||
if (textNodes.length) {
|
||||
textNodes[0].textContent = displayText;
|
||||
} else {
|
||||
weightLabel.appendChild(document.createTextNode(displayText));
|
||||
textNodes[0].textContent =
|
||||
weighting && weighting !== "processing"
|
||||
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
|
||||
: "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);
|
||||
}
|
||||
|
||||
export const isFirefox =
|
||||
navigator.userAgent.toLowerCase().indexOf("firefox") > -1 &&
|
||||
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
|
||||
!navigator.userAgent.toLowerCase().includes("waterfox");
|
||||
|
||||
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
const isBlobUrl = url.startsWith("blob:");
|
||||
|
||||
if (isBlobUrl || isFirefox) {
|
||||
if (isBlobUrl || detectFirefox()) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||
const escapedUrl = url.replace(/'/g, "\\'");
|
||||
const escapedUrl = escJsSingleQuoted(url);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -262,11 +281,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
|
||||
export async function extractPDFText(url: string): Promise<string> {
|
||||
try {
|
||||
if (isFirefox) {
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } =
|
||||
getPdfjsPageContextUrls();
|
||||
const escJsSingleQuoted = (s: string) =>
|
||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
if (detectFirefox()) {
|
||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||
|
||||
@@ -274,10 +290,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const escapedUrl = url
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"');
|
||||
const escapedUrl = escJsInlineUrl(url);
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -524,7 +537,7 @@ async function handleWeightings(mark: any, api: any) {
|
||||
text = await extractPDFText(pdfUrl);
|
||||
} catch (error: any) {
|
||||
if (
|
||||
isFirefox &&
|
||||
detectFirefox() &&
|
||||
(error?.message?.includes("blob") ||
|
||||
error?.message?.includes("Security") ||
|
||||
error?.message?.includes("CSP"))
|
||||
@@ -536,11 +549,22 @@ async function handleWeightings(mark: any, api: any) {
|
||||
}
|
||||
}
|
||||
|
||||
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
||||
let weight: string | undefined;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
weight = extractWeightFromPdfText(text);
|
||||
} catch {
|
||||
weight = undefined;
|
||||
}
|
||||
}
|
||||
if (weight === undefined) {
|
||||
const match = text.match(/weight:\s*(\d+\.?\d*)/i);
|
||||
weight = match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
api.storage.weightings = {
|
||||
...api.storage.weightings,
|
||||
[assessmentID]: match ? match[1] : "N/A",
|
||||
[assessmentID]: weight ?? "N/A",
|
||||
};
|
||||
} catch (error: any) {
|
||||
api.storage.weightings = {
|
||||
@@ -555,11 +579,7 @@ export async function parseAssessments(api: any) {
|
||||
"[class*='AssessmentList__items___']",
|
||||
).getState();
|
||||
|
||||
const marks = [
|
||||
...(state["marks"] ?? []),
|
||||
...(state["upcoming"] ?? []),
|
||||
...(state["pending"] ?? []),
|
||||
];
|
||||
const marks = state["marks"];
|
||||
if (!marks) return;
|
||||
|
||||
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
|
||||
@@ -572,6 +592,15 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
let count = 0;
|
||||
|
||||
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(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
);
|
||||
@@ -581,23 +610,12 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
if (!title) continue;
|
||||
|
||||
const assessmentID = api.storage.assessments?.[title];
|
||||
const autoWeighting = assessmentID
|
||||
const weighting = assessmentID
|
||||
? api.storage.weightings?.[assessmentID]
|
||||
: undefined;
|
||||
const override = assessmentID
|
||||
? api.storage.weightingOverrides?.[assessmentID]
|
||||
: undefined;
|
||||
const weighting = override ?? autoWeighting;
|
||||
|
||||
createWeightLabel(assessmentItem, weighting);
|
||||
|
||||
const gradeElement = assessmentItem.querySelector(
|
||||
`[class*='Thermoscore__text___']`,
|
||||
);
|
||||
if (!gradeElement) continue;
|
||||
const grade = parseGrade(gradeElement.textContent || "");
|
||||
if (grade <= 0) continue;
|
||||
|
||||
if (
|
||||
weighting === null ||
|
||||
weighting === undefined ||
|
||||
@@ -605,7 +623,8 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
weighting === "processing"
|
||||
) {
|
||||
hasInaccurateWeighting = true;
|
||||
continue
|
||||
weightedTotal += grade;
|
||||
totalWeight += 1;
|
||||
} else {
|
||||
const weight = parseFloat(weighting);
|
||||
|
||||
@@ -628,271 +647,3 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,9 +28,17 @@ async function fetchJSON(url: string, body: any) {
|
||||
|
||||
async function loadSubjects() {
|
||||
const res = await fetchJSON("/seqta/student/load/subjects?", {});
|
||||
return res.payload
|
||||
.filter((s: any) => s.active === 1)
|
||||
const activeGroup = res.payload.find((s: any) => s.active === 1);
|
||||
const activeYear = activeGroup?.year;
|
||||
const allSubjects = res.payload
|
||||
.filter((s: any) => s.year === activeYear)
|
||||
.flatMap((s: any) => s.subjects);
|
||||
const seen = new Set<string>();
|
||||
return allSubjects.filter((s: Subject) => {
|
||||
if (seen.has(s.code)) return false;
|
||||
seen.add(s.code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPrefs(student: number) {
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,4 +1,8 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
isFirefoxUserAgent,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* Detects if the current browser is Firefox
|
||||
@@ -11,6 +15,13 @@ export function isFirefox(): boolean {
|
||||
}
|
||||
// Fallback: check user agent
|
||||
if (typeof navigator !== "undefined") {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return isFirefoxUserAgent(navigator.userAgent);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
clearCustomThemeAdaptiveCssVariables,
|
||||
setCustomThemeAdaptiveCssVariables,
|
||||
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||
import {
|
||||
decodeBase64,
|
||||
isBetterseqtaWasmReady,
|
||||
stripDataUrlBase64Payload,
|
||||
} from "@/wasm/init";
|
||||
|
||||
type ThemeContent = {
|
||||
id: string;
|
||||
@@ -1087,8 +1092,15 @@ export class ThemeManager {
|
||||
private stripBase64Prefix(base64String: string): string {
|
||||
if (!base64String) return "";
|
||||
|
||||
const prefixRegex = /^data:[^;]+;base64,/;
|
||||
try {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return stripDataUrlBase64Payload(base64String);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const prefixRegex = /^data:[^;]+;base64,/;
|
||||
return prefixRegex.test(base64String)
|
||||
? base64String.replace(prefixRegex, "")
|
||||
: base64String;
|
||||
@@ -1100,6 +1112,16 @@ export class ThemeManager {
|
||||
|
||||
private base64ToBlob(base64: string): Blob {
|
||||
try {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const bytes = decodeBase64(base64.trim());
|
||||
if (bytes.byteLength > 0) {
|
||||
return new Blob([bytes], { type: "image/png" });
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const byteString = atob(base64);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
|
||||
@@ -10,7 +10,6 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
|
||||
import profilePicturePlugin from "./built-in/profilePicture";
|
||||
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
|
||||
import backgroundMusicPlugin from "./built-in/backgroundMusic";
|
||||
import betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity";
|
||||
//import messageFoldersPlugin from "./built-in/messageFolders";
|
||||
//import testPlugin from './built-in/test';
|
||||
|
||||
@@ -22,7 +21,6 @@ const pluginManager = PluginManager.getInstance();
|
||||
|
||||
// Register built-in plugins
|
||||
pluginManager.registerPlugin(themesPlugin);
|
||||
pluginManager.registerPlugin(betterSeqtaSecurityPlugin);
|
||||
pluginManager.registerPlugin(animatedBackgroundPlugin);
|
||||
pluginManager.registerPlugin(assessmentsAveragePlugin);
|
||||
pluginManager.registerPlugin(notificationCollectorPlugin);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Third-party libraries
|
||||
import browser from "webextension-polyfill";
|
||||
import { animate, stagger } from "motion";
|
||||
|
||||
// Internal utilities and functions
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||
|
||||
@@ -109,7 +109,7 @@ export async function finishLoad() {
|
||||
}
|
||||
|
||||
export function GetCSSElement(file: string) {
|
||||
const cssFile = browser.runtime.getURL(file);
|
||||
const cssFile = extensionAssetUrl(file);
|
||||
const fileref = document.createElement("link");
|
||||
fileref.setAttribute("rel", "stylesheet");
|
||||
fileref.setAttribute("type", "text/css");
|
||||
@@ -866,7 +866,7 @@ function InjectCustomIcons() {
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'IconFamily';
|
||||
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
|
||||
src: url('${extensionAssetUrl(IconFamily)}') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}`;
|
||||
|
||||
+7
-2
@@ -1,8 +1,8 @@
|
||||
// Third-party libraries
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
// Internal utilities and functions
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
// UI and theme management
|
||||
import pageState from "@/pageState.js?url";
|
||||
@@ -34,7 +34,12 @@ export async function main() {
|
||||
}
|
||||
|
||||
function injectPageState() {
|
||||
const scriptUrl = extensionAssetUrl(pageState);
|
||||
if (!scriptUrl) {
|
||||
console.warn("[BetterSEQTA+] Could not resolve pageState script URL.");
|
||||
return;
|
||||
}
|
||||
const mainScript = document.createElement("script");
|
||||
mainScript.src = browser.runtime.getURL(pageState);
|
||||
mainScript.src = scriptUrl;
|
||||
document.head.appendChild(mainScript);
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import Color from "color";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||
@@ -6,6 +5,7 @@ import ColorLuminance from "./ColorLuminance";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||
@@ -115,11 +115,11 @@ function applyColorsWith(selectedColor: string) {
|
||||
let modeProps = {};
|
||||
modeProps = settingsState.DarkMode
|
||||
? {
|
||||
"--betterseqta-logo": `url(${browser.runtime.getURL(darkLogo)})`,
|
||||
"--betterseqta-logo": `url(${extensionAssetUrl(darkLogo)})`,
|
||||
}
|
||||
: {
|
||||
"--better-pale": lightenAndPaleColor(selectedColor),
|
||||
"--betterseqta-logo": `url(${browser.runtime.getURL(lightLogo)})`,
|
||||
"--betterseqta-logo": `url(${extensionAssetUrl(lightLogo)})`,
|
||||
};
|
||||
|
||||
if (settingsState.DarkMode) {
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
import Color from "color";
|
||||
import {
|
||||
colorCssThresholdDistance,
|
||||
isBetterseqtaWasmReady,
|
||||
} from "@/wasm/init";
|
||||
|
||||
export function GetThresholdOfColor(color: any) {
|
||||
if (!color) return 0;
|
||||
// Case-insensitive regular expression for matching RGBA colors
|
||||
const s = typeof color === "string" ? color : String(color);
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const v = colorCssThresholdDistance(s);
|
||||
if (v >= 0 && Number.isFinite(v)) return v;
|
||||
} catch {
|
||||
/* fall through to Color */
|
||||
}
|
||||
}
|
||||
|
||||
const rgbaRegex = /rgba?\(([^)]+)\)/gi;
|
||||
|
||||
// Check if the color string is a gradient (linear or radial)
|
||||
if (color.includes("gradient")) {
|
||||
let gradientThresholds = [];
|
||||
|
||||
// Find and replace all instances of RGBA in the gradient
|
||||
if (s.includes("gradient")) {
|
||||
const gradientThresholds = [];
|
||||
let match;
|
||||
while ((match = rgbaRegex.exec(color)) !== null) {
|
||||
// Extract the individual components (r, g, b, a)
|
||||
while ((match = rgbaRegex.exec(s)) !== null) {
|
||||
const rgbaString = match[1];
|
||||
const [r, g, b] = rgbaString.split(",").map((str) => str.trim());
|
||||
|
||||
// Compute the threshold using your existing algorithm
|
||||
const threshold = Math.sqrt(
|
||||
parseInt(r) ** 2 + parseInt(g) ** 2 + parseInt(b) ** 2,
|
||||
parseInt(r, 10) ** 2 + parseInt(g, 10) ** 2 + parseInt(b, 10) ** 2,
|
||||
);
|
||||
|
||||
// Store the computed threshold
|
||||
gradientThresholds.push(threshold);
|
||||
}
|
||||
|
||||
// Calculate the average threshold
|
||||
const averageThreshold =
|
||||
if (gradientThresholds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
||||
gradientThresholds.length;
|
||||
|
||||
return averageThreshold;
|
||||
} else {
|
||||
// Handle the color as a simple RGBA (or hex, or whatever the Color library supports)
|
||||
const rgb = Color.rgb(color).object();
|
||||
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2);
|
||||
gradientThresholds.length
|
||||
);
|
||||
}
|
||||
|
||||
const rgb = Color.rgb(s).object();
|
||||
return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { animate } from "motion";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||
@@ -17,8 +16,7 @@ import {
|
||||
toISODate,
|
||||
weekRangeContaining,
|
||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||
const home = document.getElementById("homebutton");
|
||||
@@ -130,7 +128,7 @@ function renderEngageDayLessons(): void {
|
||||
if (lessons.length === 0) {
|
||||
dayContainer.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
||||
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||
<p>No lessons for this day.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
@@ -275,7 +273,7 @@ function processEngageNotices(response: any, labelArray: string[]): void {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -287,7 +285,7 @@ function processEngageNotices(response: any, labelArray: string[]): void {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -358,12 +356,6 @@ function openEngageNoticeModal(
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.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();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -397,7 +389,7 @@ function openEngageNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
${noticeBodyInner}
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,23 +406,6 @@ function openEngageNoticeModal(
|
||||
|
||||
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.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
@@ -456,11 +431,7 @@ function openEngageNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
@@ -742,7 +713,7 @@ function showEngageTimetableError(message: string): void {
|
||||
dayContainer.classList.remove("loading");
|
||||
dayContainer.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
||||
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||
<p>${message}</p>
|
||||
</div>`;
|
||||
}
|
||||
@@ -753,7 +724,7 @@ function showEngageNoticesSectionError(message: string): void {
|
||||
noticeContainer.classList.remove("loading");
|
||||
noticeContainer.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
||||
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||
<p>${message}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { animate, stagger } from "motion";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||
import coursesicon from "@/seqta/icons/coursesIcon";
|
||||
@@ -13,8 +12,7 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||
import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats";
|
||||
import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
let LessonInterval: any;
|
||||
let currentSelectedDate = new Date();
|
||||
@@ -115,7 +113,16 @@ export async function loadHomePage() {
|
||||
callHomeTimetable(TodayFormatted, true);
|
||||
|
||||
const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
|
||||
const activeSubjects = activeClass?.subjects || [];
|
||||
const activeYear = activeClass?.year;
|
||||
const allSubjectsInYear = classes
|
||||
.filter((c: any) => c.year === activeYear)
|
||||
.flatMap((c: any) => c.subjects || []);
|
||||
const seen = new Set<string>();
|
||||
const activeSubjects = allSubjectsInYear.filter((s: any) => {
|
||||
if (seen.has(s.code)) return false;
|
||||
seen.add(s.code);
|
||||
return true;
|
||||
});
|
||||
const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
|
||||
const currentAssessments = assessments
|
||||
.filter((a: any) => activeSubjectCodes.includes(a.code))
|
||||
@@ -148,7 +155,7 @@ export async function loadHomePage() {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices available.";
|
||||
emptyState.append(img, text);
|
||||
@@ -297,7 +304,7 @@ function processNotices(response: any, labelArray: string[]) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -309,7 +316,7 @@ function processNotices(response: any, labelArray: string[]) {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No notices for today.";
|
||||
emptyState.append(img, text);
|
||||
@@ -386,12 +393,6 @@ function openNoticeModal(
|
||||
.replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "")
|
||||
.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();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -425,7 +426,7 @@ function openNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
${noticeBodyInner}
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,23 +443,6 @@ function openNoticeModal(
|
||||
|
||||
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.style.opacity = "0";
|
||||
sourceElement.style.transform = "scale(0.95)";
|
||||
@@ -484,11 +468,7 @@ function openNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
@@ -755,7 +735,7 @@ function callHomeTimetable(date: string, change?: any) {
|
||||
const dummyDay = document.createElement("div");
|
||||
dummyDay.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLight);
|
||||
img.src = extensionAssetUrl(LogoLight);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No lessons available.";
|
||||
dummyDay.append(img, text);
|
||||
@@ -1007,7 +987,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
||||
if (assessments.length === 0) {
|
||||
upcomingitemcontainer!.innerHTML = `
|
||||
<div class="day-empty">
|
||||
<img src="${browser.runtime.getURL(LogoLight)}" />
|
||||
<img src="${extensionAssetUrl(LogoLight)}" />
|
||||
<p>No assessments available.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
|
||||
const text = stringToHTML(/* html */ `
|
||||
<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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { settingsState } from "./listeners/SettingsState";
|
||||
import browser from "webextension-polyfill";
|
||||
import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png";
|
||||
import { animate, stagger } from "motion";
|
||||
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||
|
||||
export async function SendNewsPage() {
|
||||
console.info("[BetterSEQTA+] Started Loading News Page");
|
||||
@@ -58,7 +59,7 @@ export async function SendNewsPage() {
|
||||
const emptyState = document.createElement("div");
|
||||
emptyState.classList.add("day-empty");
|
||||
const img = document.createElement("img");
|
||||
img.src = browser.runtime.getURL(LogoLightOutline);
|
||||
img.src = extensionAssetUrl(LogoLightOutline);
|
||||
const text = document.createElement("p");
|
||||
text.innerText = "No news articles available right now.";
|
||||
emptyState.append(img, text);
|
||||
@@ -79,7 +80,7 @@ export async function SendNewsPage() {
|
||||
|
||||
if (article.urlToImage == "null" || article.urlToImage == null) {
|
||||
articleimage.style.cssText = `
|
||||
background-image: url(${browser.runtime.getURL(LogoLightOutline)});
|
||||
background-image: url(${extensionAssetUrl(LogoLightOutline)});
|
||||
width: 20%;
|
||||
margin: 0 7.5%;
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* Parses the current page from window.location.hash.
|
||||
@@ -12,6 +17,24 @@ import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||
*/
|
||||
function parsePageContext(): { programme: number; metaclass: number } | null {
|
||||
const hash = window.location.hash || "";
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const json = parseSeqtaCoursesAssessmentsPageJson(hash);
|
||||
if (json) {
|
||||
const o = JSON.parse(json) as { programme: number; metaclass: number };
|
||||
if (
|
||||
typeof o.programme === "number" &&
|
||||
typeof o.metaclass === "number" &&
|
||||
!Number.isNaN(o.programme) &&
|
||||
!Number.isNaN(o.metaclass)
|
||||
) {
|
||||
return { programme: o.programme, metaclass: o.metaclass };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
|
||||
if (!match) return null;
|
||||
const programme = parseInt(match[2], 10);
|
||||
@@ -112,6 +135,14 @@ export async function getAdaptiveColour(): Promise<string | null> {
|
||||
const colour = await getSubjectColour(subjectCode, userId);
|
||||
if (!colour || typeof colour !== "string") return null;
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const normalized = normalizeSeqtaSubjectHexColour(colour);
|
||||
if (normalized) return normalized;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
// Basic hex validation
|
||||
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(colour)) return `#${colour}`;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
import { decodeBase64, isBetterseqtaWasmReady } from "@/wasm/init";
|
||||
|
||||
const base64ToBlobTs = (base64: string, contentType: string = ""): Blob => {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteArrays: Uint8Array[] = [];
|
||||
|
||||
@@ -14,4 +16,15 @@ const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
return new Blob(byteArrays, { type: contentType });
|
||||
};
|
||||
|
||||
const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
||||
const trimmed = base64.trim();
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
const bytes = decodeBase64(trimmed);
|
||||
if (bytes.byteLength > 0 || trimmed.length === 0) {
|
||||
return new Blob([bytes], { type: contentType });
|
||||
}
|
||||
}
|
||||
return base64ToBlobTs(trimmed, contentType);
|
||||
};
|
||||
|
||||
export default base64ToBlob;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const blobToBase64 = (blob: Blob) => {
|
||||
import { encodeDataUrl, isBetterseqtaWasmReady } from "@/wasm/init";
|
||||
|
||||
function readAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -8,4 +10,21 @@ export const blobToBase64 = (blob: Blob) => {
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export const blobToBase64 = (blob: Blob) => {
|
||||
return (async () => {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const buf = await blob.arrayBuffer();
|
||||
return encodeDataUrl(
|
||||
blob.type || "application/octet-stream",
|
||||
new Uint8Array(buf),
|
||||
);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return readAsDataUrl(blob);
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export function convertTo12HourFormat(
|
||||
import {
|
||||
convertTo12HourFormatWasm,
|
||||
isBetterseqtaWasmReady,
|
||||
} from "@/wasm/init";
|
||||
|
||||
function convertTo12HourFormatTs(
|
||||
time: string,
|
||||
noMinutes: boolean = false,
|
||||
): string {
|
||||
@@ -19,3 +24,18 @@ export function convertTo12HourFormat(
|
||||
|
||||
return `${hoursStr}${noMinutes ? "" : `:${minutes.toString().padStart(2, "0")}`}${period}`;
|
||||
}
|
||||
|
||||
/** 12-hour time label; Rust/WASM when initialized, else TypeScript. */
|
||||
export function convertTo12HourFormat(
|
||||
time: string,
|
||||
noMinutes: boolean = false,
|
||||
): string {
|
||||
if (!isBetterseqtaWasmReady()) {
|
||||
return convertTo12HourFormatTs(time, noMinutes);
|
||||
}
|
||||
try {
|
||||
return convertTo12HourFormatWasm(time, noMinutes);
|
||||
} catch {
|
||||
return convertTo12HourFormatTs(time, noMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import {
|
||||
isBetterseqtaWasmReady,
|
||||
parseEngageRoutePage,
|
||||
} from "@/wasm/init";
|
||||
|
||||
/**
|
||||
* Learn-style hash routes on Engage: `#?page=/home` → `"home"`.
|
||||
* Falls back to the legacy path segment used by classic Learn routing.
|
||||
*/
|
||||
export function getEngageRoutePage(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return parseEngageRoutePage(
|
||||
window.location.hash,
|
||||
window.location.href,
|
||||
);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, "");
|
||||
if (hash) {
|
||||
const qs = hash.startsWith("?") ? hash : `?${hash}`;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
function hasExtensionRuntimeGetUrl(): boolean {
|
||||
const runtime = (globalThis as any)?.chrome?.runtime;
|
||||
return (
|
||||
!!runtime &&
|
||||
typeof runtime.getURL === "function" &&
|
||||
typeof runtime.id === "string" &&
|
||||
runtime.id.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an extension asset URL safely across content-script and page contexts.
|
||||
* Falls back to the provided path when extension runtime APIs are unavailable.
|
||||
*/
|
||||
export function extensionAssetUrl(path: string): string {
|
||||
if (!path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Already fully-resolved URL.
|
||||
if (
|
||||
path.startsWith("chrome-extension://") ||
|
||||
path.startsWith("moz-extension://") ||
|
||||
path.startsWith("http://") ||
|
||||
path.startsWith("https://") ||
|
||||
path.startsWith("data:") ||
|
||||
path.startsWith("blob:")
|
||||
) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (hasExtensionRuntimeGetUrl()) {
|
||||
return (globalThis as any).chrome.runtime.getURL(path.replace(/^\/+/, ""));
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import {
|
||||
decodeBase64,
|
||||
isBetterseqtaWasmReady,
|
||||
stripDataUrlBase64Payload,
|
||||
} from "@/wasm/init";
|
||||
|
||||
export function base64toblobURL(base64: string) {
|
||||
// Extract base64 data from the data URI
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const payload = stripDataUrlBase64Payload(base64);
|
||||
const bytes = decodeBase64(payload.trim());
|
||||
if (bytes.byteLength > 0) {
|
||||
const blob = new Blob([bytes], { type: "image/png" });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const base64Index = base64.indexOf(",") + 1;
|
||||
const imageBase64 = base64.substring(base64Index);
|
||||
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageBase64);
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
@@ -12,8 +29,5 @@ export function base64toblobURL(base64: string) {
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: "image/png" });
|
||||
|
||||
// Convert blob to blob URL
|
||||
const imageUrl = URL.createObjectURL(blob);
|
||||
|
||||
return imageUrl;
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { isBetterseqtaWasmReady, titleIsSeqtaEngage } from "@/wasm/init";
|
||||
|
||||
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
|
||||
export function isSeqtaEngageExperience(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return titleIsSeqtaEngage(document.title);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return document.title.includes("SEQTA Engage");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import {
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isBetterseqtaWasmReady,
|
||||
locationHashIncludesTimetablePage,
|
||||
} from "@/wasm/init";
|
||||
import { convertTo12HourFormat } from "./convertTo12HourFormat";
|
||||
import { waitForElm } from "./waitForElm";
|
||||
|
||||
function timetableLabel12(original: string, noMinutes: boolean): string {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return formatTimetableTimeLabel(original, noMinutes);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return convertTo12HourFormat(original, noMinutes)
|
||||
.toLowerCase()
|
||||
.replace(" ", "");
|
||||
}
|
||||
|
||||
function timetableRange12(original: string): string | undefined {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
const r = formatTimetableTimeRange(original);
|
||||
if (r) return r;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
if (!original.includes("–") && !original.includes("-")) return undefined;
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
if (!start || !end) return undefined;
|
||||
return `${timetableLabel12(start, false)}–${timetableLabel12(end, false)}`;
|
||||
}
|
||||
|
||||
let timetableObserver: MutationObserver | null = null;
|
||||
let isOnTimetablePage = false;
|
||||
let isInitialized = false;
|
||||
@@ -19,9 +53,7 @@ function updateTimeElements(): void {
|
||||
const original = el.dataset.original;
|
||||
if (!original) return;
|
||||
|
||||
el.textContent = convertTo12HourFormat(original, true)
|
||||
.toLowerCase()
|
||||
.replace(" ", "");
|
||||
el.textContent = timetableLabel12(original, true);
|
||||
});
|
||||
|
||||
const entryTimes = timetablePage.querySelectorAll<HTMLElement>(".entry .times");
|
||||
@@ -30,30 +62,30 @@ function updateTimeElements(): void {
|
||||
const original = el.dataset.original || "";
|
||||
if (!original.includes("–") && !original.includes("-")) return;
|
||||
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
if (!start || !end) return;
|
||||
|
||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
||||
el.textContent = `${start12}–${end12}`;
|
||||
const ranged = timetableRange12(original);
|
||||
if (ranged) el.textContent = ranged;
|
||||
});
|
||||
|
||||
const quickbarTimes = document.querySelectorAll<HTMLElement>(".quickbar .meta .times");
|
||||
const quickbarTimes = document.querySelectorAll<HTMLElement>(
|
||||
".quickbar .meta .times",
|
||||
);
|
||||
quickbarTimes.forEach((el) => {
|
||||
if (!el.dataset.original) el.dataset.original = el.textContent || "";
|
||||
const original = el.dataset.original || "";
|
||||
|
||||
if (!original.includes("–") && !original.includes("-")) return;
|
||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
||||
|
||||
if (!start || !end) return;
|
||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
||||
el.textContent = `${start12}–${end12}`;
|
||||
const ranged = timetableRange12(original);
|
||||
if (ranged) el.textContent = ranged;
|
||||
});
|
||||
}
|
||||
|
||||
function checkIfOnTimetablePage(): boolean {
|
||||
if (isBetterseqtaWasmReady()) {
|
||||
try {
|
||||
return locationHashIncludesTimetablePage(window.location.hash);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return window.location.hash.includes("page=/timetable");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* wasm-bindgen bundle (`wasm-pack --target web`): call {@link initBetterseqtaWasm}
|
||||
* before using exports that touch the module instance.
|
||||
*/
|
||||
import wasmInit, {
|
||||
childTextHasSeqtaCopyright,
|
||||
colorCssThresholdDistance,
|
||||
convertTo12HourFormat as convertTo12HourFormatWasm,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
encodeDataUrl,
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extensionWasmVersion,
|
||||
extractWeightFromPdfText,
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isFirefoxUserAgent,
|
||||
locationHashIncludesTimetablePage,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseEngageRoutePage,
|
||||
parseGradeToPercent,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
stripDataUrlBase64Payload,
|
||||
titleIsSeqtaEngage,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
} from "@/wasm/pkg/betterseqta_wasm.js";
|
||||
|
||||
let wasmReady = false;
|
||||
let wasmInflight: Promise<void> | null = null;
|
||||
|
||||
export async function initBetterseqtaWasm(): Promise<void> {
|
||||
if (wasmReady) return;
|
||||
if (!wasmInflight) {
|
||||
wasmInflight = wasmInit().then(() => {
|
||||
wasmReady = true;
|
||||
});
|
||||
}
|
||||
await wasmInflight;
|
||||
}
|
||||
|
||||
export function isBetterseqtaWasmReady(): boolean {
|
||||
return wasmReady;
|
||||
}
|
||||
|
||||
export {
|
||||
childTextHasSeqtaCopyright,
|
||||
colorCssThresholdDistance,
|
||||
convertTo12HourFormatWasm,
|
||||
decodeBase64,
|
||||
encodeBase64,
|
||||
encodeDataUrl,
|
||||
escapeJsForInlineScript,
|
||||
escapeJsSingleQuotedString,
|
||||
extensionWasmVersion,
|
||||
extractWeightFromPdfText,
|
||||
formatTimetableTimeLabel,
|
||||
formatTimetableTimeRange,
|
||||
isFirefoxUserAgent,
|
||||
locationHashIncludesTimetablePage,
|
||||
normalizeSeqtaSubjectHexColour,
|
||||
parseEngageRoutePage,
|
||||
parseGradeToPercent,
|
||||
parseSeqtaCoursesAssessmentsPageJson,
|
||||
stripDataUrlBase64Payload,
|
||||
titleIsSeqtaEngage,
|
||||
titleIsSeqtaLearnOrEngage,
|
||||
};
|
||||
@@ -28,6 +28,9 @@ const mode = process.env.MODE || "chrome"; // Check the environment variable to
|
||||
const useMillion = mode.toLowerCase() !== "firefox";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
// Default "/" makes Vite's modulepreload helper resolve deps as "/assets/…", which loads from the
|
||||
// page origin in content scripts. Relative base uses `new URL(dep, import.meta.url)` instead.
|
||||
base: "./",
|
||||
plugins: [
|
||||
base64Loader,
|
||||
InlineWorkerPlugin(),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "betterseqta-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["BetterSEQTA+"]
|
||||
description = "WebAssembly helpers for the BetterSEQTA+ browser extension"
|
||||
repository = "https://github.com/BetterSEQTA/BetterSEQTA-Plus"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
base64 = "0.22.1"
|
||||
percent-encoding = "2.3.1"
|
||||
regex = "1.11.1"
|
||||
csscolorparser = "0.7.2"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Base64 and data-URL helpers.
|
||||
|
||||
use base64::Engine;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = encodeBase64)]
|
||||
pub fn encode_base64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = decodeBase64)]
|
||||
pub fn decode_base64(b64: &str) -> Vec<u8> {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.trim())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `data:{mime};base64,{payload}` (mirrors `readAsDataURL` output shape).
|
||||
#[wasm_bindgen(js_name = encodeDataUrl)]
|
||||
pub fn encode_data_url(mime: &str, bytes: &[u8]) -> String {
|
||||
let mime = mime.trim();
|
||||
let mime = if mime.is_empty() {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
mime
|
||||
};
|
||||
format!(
|
||||
"data:{};base64,{}",
|
||||
mime,
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
)
|
||||
}
|
||||
|
||||
/// Strips a leading `data:*;base64,` prefix; returns the original string when no prefix matches.
|
||||
#[wasm_bindgen(js_name = stripDataUrlBase64Payload)]
|
||||
pub fn strip_data_url_base64_payload(s: &str) -> String {
|
||||
let Some(rest) = s.strip_prefix("data:") else {
|
||||
return s.to_string();
|
||||
};
|
||||
let Some(i) = rest.find(";base64,") else {
|
||||
return s.to_string();
|
||||
};
|
||||
let after = &rest[i + ";base64,".len()..];
|
||||
after.to_string()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Engage hash routing (`getEngageRoutePage`).
|
||||
|
||||
use percent_encoding::percent_decode_str;
|
||||
use std::borrow::Cow;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors `getEngageRoutePage` using `window.location.hash` and `window.location.href` inputs.
|
||||
#[wasm_bindgen(js_name = parseEngageRoutePage)]
|
||||
pub fn parse_engage_route_page(hash: &str, full_href: &str) -> Option<String> {
|
||||
let hash = hash.strip_prefix('#').unwrap_or(hash);
|
||||
if !hash.is_empty() {
|
||||
let qs: Cow<'_, str> = if hash.starts_with('?') {
|
||||
Cow::Borrowed(hash)
|
||||
} else {
|
||||
Cow::Owned(format!("?{hash}"))
|
||||
};
|
||||
if let Some(seg) = parse_page_segment_from_query_string(qs.as_ref()) {
|
||||
return Some(seg);
|
||||
}
|
||||
}
|
||||
segment_from_href_split(full_href)
|
||||
}
|
||||
|
||||
fn parse_page_segment_from_query_string(qs: &str) -> Option<String> {
|
||||
let body = qs.strip_prefix('?').unwrap_or(qs);
|
||||
for pair in body.split('&') {
|
||||
let mut it = pair.splitn(2, '=');
|
||||
let key = it.next()?;
|
||||
if key != "page" {
|
||||
continue;
|
||||
}
|
||||
let enc = it.next().unwrap_or("");
|
||||
let decoded = percent_decode_str(enc).decode_utf8_lossy();
|
||||
let page = decoded.as_ref();
|
||||
if let Some(rest) = page.strip_prefix('/') {
|
||||
let seg = rest.split('/').next().unwrap_or("");
|
||||
if !seg.is_empty() {
|
||||
return Some(seg.to_string());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn segment_from_href_split(full_href: &str) -> Option<String> {
|
||||
full_href.split('/').nth(4).map(std::string::ToString::to_string)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! String escaping for injected scripts (PDF / Firefox workarounds).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = escapeJsSingleQuotedString)]
|
||||
pub fn escape_js_single_quoted_string(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
|
||||
/// `escJsSingleQuoted` plus double-quote escapes (used for some injected literals).
|
||||
#[wasm_bindgen(js_name = escapeJsForInlineScript)]
|
||||
pub fn escape_js_for_inline_script(s: &str) -> String {
|
||||
escape_js_single_quoted_string(s)
|
||||
.replace('"', "\\\"")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Grade parsing (`parseGrade` in `assessmentsAverage/utils.ts`).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn letter_grade_percent(s: &str) -> Option<f64> {
|
||||
Some(match s {
|
||||
"A+" => 100.0,
|
||||
"A" => 95.0,
|
||||
"A-" => 90.0,
|
||||
"B+" => 85.0,
|
||||
"B" => 80.0,
|
||||
"B-" => 75.0,
|
||||
"C+" => 70.0,
|
||||
"C" => 65.0,
|
||||
"C-" => 60.0,
|
||||
"D+" => 55.0,
|
||||
"D" => 50.0,
|
||||
"D-" => 45.0,
|
||||
"E+" => 40.0,
|
||||
"E" => 35.0,
|
||||
"E-" => 30.0,
|
||||
"F" => 0.0,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `parseGrade` (numeric percent 0–100).
|
||||
#[wasm_bindgen(js_name = parseGradeToPercent)]
|
||||
pub fn parse_grade_to_percent(text: &str) -> f64 {
|
||||
let str = text.trim().to_ascii_uppercase();
|
||||
if str.contains('/') {
|
||||
let mut parts = str.split('/');
|
||||
let raw = parts.next().and_then(|p| p.parse::<f64>().ok());
|
||||
let max = parts.next().and_then(|p| p.parse::<f64>().ok());
|
||||
if let (Some(r), Some(m)) = (raw, max) {
|
||||
if m != 0.0 {
|
||||
return (r / m) * 100.0;
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
if str.contains('%') {
|
||||
return str.replace('%', "").parse::<f64>().unwrap_or(0.0);
|
||||
}
|
||||
letter_grade_percent(&str).unwrap_or(0.0)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Subject timetable colour hex validation (`getAdaptiveColour` in `adaptiveThemeColour.ts`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn re_hash_shorthand_or_full() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)^#([0-9a-f]{3}|[0-9a-f]{6})$").expect("hex # regex"))
|
||||
}
|
||||
|
||||
fn re_plain_six() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)^[0-9a-f]{6}$").expect("hex6 regex"))
|
||||
}
|
||||
|
||||
/// Returns `#rgb` / `#rrggbb` unchanged, or adds `#` to a bare 6-digit hex; otherwise `undefined`.
|
||||
#[wasm_bindgen(js_name = normalizeSeqtaSubjectHexColour)]
|
||||
pub fn normalize_seqta_subject_hex_colour(colour: &str) -> Option<String> {
|
||||
let c = colour.trim();
|
||||
if re_hash_shorthand_or_full().is_match(c) {
|
||||
return Some(c.to_string());
|
||||
}
|
||||
if re_plain_six().is_match(c) {
|
||||
return Some(format!("#{c}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! BetterSEQTA+ WebAssembly module (wasm-bindgen).
|
||||
//! Pure helpers mirrored from the TypeScript extension.
|
||||
|
||||
mod base64_api;
|
||||
mod engage;
|
||||
mod escape;
|
||||
mod grades;
|
||||
mod hex_colour;
|
||||
mod page_context;
|
||||
mod pdf_weight;
|
||||
mod seqta;
|
||||
mod threshold;
|
||||
mod time_format;
|
||||
mod timetable_nav;
|
||||
mod user_agent;
|
||||
|
||||
pub use base64_api::{
|
||||
decode_base64, encode_base64, encode_data_url, strip_data_url_base64_payload,
|
||||
};
|
||||
pub use engage::parse_engage_route_page;
|
||||
pub use escape::{escape_js_for_inline_script, escape_js_single_quoted_string};
|
||||
pub use grades::parse_grade_to_percent;
|
||||
pub use hex_colour::normalize_seqta_subject_hex_colour;
|
||||
pub use page_context::parse_seqta_courses_assessments_page_json;
|
||||
pub use pdf_weight::extract_weight_from_pdf_text;
|
||||
pub use seqta::{
|
||||
child_text_has_seqta_copyright, title_is_seqta_engage_only, title_is_seqta_learn_or_engage,
|
||||
};
|
||||
pub use threshold::color_css_threshold_distance;
|
||||
pub use time_format::{
|
||||
convert_to_12_hour_format, format_timetable_time_label, format_timetable_time_range,
|
||||
};
|
||||
pub use timetable_nav::location_hash_includes_timetable_page;
|
||||
pub use user_agent::is_firefox_user_agent;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = extensionWasmVersion)]
|
||||
pub fn extension_wasm_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn twelve_hour_matches_ts_examples() {
|
||||
assert_eq!(convert_to_12_hour_format("13:05", false), "1:05pm");
|
||||
assert_eq!(convert_to_12_hour_format("0:30", false), "12:30am");
|
||||
assert_eq!(convert_to_12_hour_format("12:00", false), "12:00pm");
|
||||
assert_eq!(convert_to_12_hour_format("12:00", true), "12pm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seqta_strings() {
|
||||
assert!(child_text_has_seqta_copyright(
|
||||
"foo Copyright (c) SEQTA Software bar"
|
||||
));
|
||||
assert!(!child_text_has_seqta_copyright("other"));
|
||||
assert!(title_is_seqta_learn_or_engage("SEQTA Learn — Home"));
|
||||
assert!(title_is_seqta_engage_only("SEQTA Engage"));
|
||||
assert!(!title_is_seqta_engage_only("SEQTA Learn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engage_routes() {
|
||||
assert_eq!(
|
||||
parse_engage_route_page("#?page=/home/extra", "https://x.example/a/b/c/d/e"),
|
||||
Some("home".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_engage_route_page("#?page=%2Fhome%2Fextra", "https://x.example/a/b/c/d/e"),
|
||||
Some("home".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_engage_route_page("", "a/b/c/d/home/extra"),
|
||||
Some("home".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grades_and_weight() {
|
||||
assert_eq!(parse_grade_to_percent(" 12/20 "), 60.0);
|
||||
assert_eq!(parse_grade_to_percent("85%"), 85.0);
|
||||
assert_eq!(parse_grade_to_percent("A-"), 90.0);
|
||||
assert_eq!(
|
||||
extract_weight_from_pdf_text("foo Weight: 12.5 bar").as_deref(),
|
||||
Some("12.5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firefox_ua() {
|
||||
assert!(is_firefox_user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/115.0"
|
||||
));
|
||||
assert!(!is_firefox_user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timetable_hash() {
|
||||
assert!(location_hash_includes_timetable_page("#?page=/timetable"));
|
||||
assert!(!location_hash_includes_timetable_page("#?page=/home"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_context_json() {
|
||||
let j = parse_seqta_courses_assessments_page_json(
|
||||
"#?page=/courses/2023S/4804:11066",
|
||||
)
|
||||
.expect("json");
|
||||
assert!(j.contains("\"programme\":4804"));
|
||||
assert!(j.contains("\"metaclass\":11066"));
|
||||
let j2 = parse_seqta_courses_assessments_page_json("#?page=/courses/4804:11066")
|
||||
.expect("json2");
|
||||
assert!(j2.contains("4804"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_subject_colour() {
|
||||
assert_eq!(
|
||||
normalize_seqta_subject_hex_colour("#aBc").as_deref(),
|
||||
Some("#aBc")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_seqta_subject_hex_colour("aabbcc").as_deref(),
|
||||
Some("#aabbcc")
|
||||
);
|
||||
assert!(normalize_seqta_subject_hex_colour("gggggg").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_basic() {
|
||||
let t = color_css_threshold_distance("rgb(3,4,5)");
|
||||
assert!((t - (3f64 * 3.0 + 4.0 * 4.0 + 5.0 * 5.0).sqrt()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! `#?page=/courses/...` and `#?page=/assessments/...` programme:metaclass parsing
|
||||
//! (mirrors `parsePageContext` in `adaptiveThemeColour.ts`).
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn page_ctx_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"[?&]page=/(courses|assessments)/(?:[^/]+/)?(\d+):(\d+)")
|
||||
.expect("page context regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON `{"programme":n,"metaclass":m}` or `undefined` when the hash does not match.
|
||||
#[wasm_bindgen(js_name = parseSeqtaCoursesAssessmentsPageJson)]
|
||||
pub fn parse_seqta_courses_assessments_page_json(hash: &str) -> Option<String> {
|
||||
let cap = page_ctx_regex().captures(hash)?;
|
||||
let programme: i32 = cap.get(2)?.as_str().parse().ok()?;
|
||||
let metaclass: i32 = cap.get(3)?.as_str().parse().ok()?;
|
||||
if programme < 0 || metaclass < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
r#"{{"programme":{programme},"metaclass":{metaclass}}}"#
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! PDF text weight extraction (`/weight:\s*(\d+\.?\d*)/i` in assessments average).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Returns the first `weight:` numeric capture, or `undefined` when absent.
|
||||
#[wasm_bindgen(js_name = extractWeightFromPdfText)]
|
||||
pub fn extract_weight_from_pdf_text(text: &str) -> Option<String> {
|
||||
let lower: Vec<u8> = text.bytes().map(|b| b.to_ascii_lowercase()).collect();
|
||||
let needle = b"weight:";
|
||||
let bytes = text.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i + needle.len() <= lower.len() {
|
||||
if lower[i..i + needle.len()] == needle[..] {
|
||||
let mut j = i + needle.len();
|
||||
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
let start = j;
|
||||
while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') {
|
||||
j += 1;
|
||||
}
|
||||
if j > start {
|
||||
return Some(text[start..j].to_string());
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! SEQTA page / title detection strings.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = childTextHasSeqtaCopyright)]
|
||||
pub fn child_text_has_seqta_copyright(text: &str) -> bool {
|
||||
text.contains("Copyright (c) SEQTA Software")
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = titleIsSeqtaLearnOrEngage)]
|
||||
pub fn title_is_seqta_learn_or_engage(title: &str) -> bool {
|
||||
title.contains("SEQTA Learn") || title.contains("SEQTA Engage")
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = titleIsSeqtaEngage)]
|
||||
pub fn title_is_seqta_engage_only(title: &str) -> bool {
|
||||
title.contains("SEQTA Engage")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! `GetThresholdOfColor` luminance distance (`sqrt(r²+g²+b²)`), including gradients.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
fn rgba_capture_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)rgba?\(([^)]+)\)").expect("regex"))
|
||||
}
|
||||
|
||||
fn parse_js_int_channel(s: &str) -> f64 {
|
||||
s.trim().parse::<f64>().ok().map(|n| n.trunc()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn threshold_from_rgb_triplet(r: f64, g: f64, b: f64) -> f64 {
|
||||
(r * r + g * g + b * b).sqrt()
|
||||
}
|
||||
|
||||
fn gradient_average_threshold(color: &str) -> Option<f64> {
|
||||
let re = rgba_capture_regex();
|
||||
let mut sums = Vec::new();
|
||||
for cap in re.captures_iter(color) {
|
||||
let inner = cap.get(1)?.as_str();
|
||||
let parts: Vec<&str> = inner.split(',').collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let r = parse_js_int_channel(parts[0]);
|
||||
let g = parse_js_int_channel(parts[1]);
|
||||
let b = parse_js_int_channel(parts[2]);
|
||||
sums.push(threshold_from_rgb_triplet(r, g, b));
|
||||
}
|
||||
if sums.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sums.iter().sum::<f64>() / sums.len() as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `sqrt(r²+g²+b²)` for a CSS color string, or **`-1`** when the Rust path
|
||||
/// cannot match the JS `color` package (caller should fall back to TypeScript).
|
||||
#[wasm_bindgen(js_name = colorCssThresholdDistance)]
|
||||
pub fn color_css_threshold_distance(color: &str) -> f64 {
|
||||
let color = color.trim();
|
||||
if color.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if color.contains("gradient") {
|
||||
return gradient_average_threshold(color).unwrap_or(-1.0);
|
||||
}
|
||||
match csscolorparser::parse(color) {
|
||||
Ok(c) => {
|
||||
let rgba = c.to_rgba8();
|
||||
let r = f64::from(rgba[0]);
|
||||
let g = f64::from(rgba[1]);
|
||||
let b = f64::from(rgba[2]);
|
||||
threshold_from_rgb_triplet(r, g, b)
|
||||
}
|
||||
Err(_) => -1.0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 12-hour timetable formatting (mirrors TS helpers around `convertTo12HourFormat`).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Mirrors JavaScript `Number(s.trim())` for a single split segment, including `"" -> 0`.
|
||||
fn js_number_from_split_segment(part: Option<&str>) -> f64 {
|
||||
let s = part.unwrap_or("");
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
trimmed.parse::<f64>().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Mirrors `n.toString()` for timetable paths.
|
||||
fn js_number_to_string(n: f64) -> String {
|
||||
if n.is_nan() {
|
||||
return "NaN".to_string();
|
||||
}
|
||||
if n == 0.0 && n.is_sign_negative() {
|
||||
return "0".to_string();
|
||||
}
|
||||
if (n - n.round()).abs() < f64::EPSILON {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 1:1 with `convertTo12HourFormat` in `src/seqta/utils/convertTo12HourFormat.ts`.
|
||||
#[wasm_bindgen(js_name = convertTo12HourFormat)]
|
||||
pub fn convert_to_12_hour_format(time: &str, no_minutes: bool) -> String {
|
||||
let parts: Vec<&str> = time.split(':').collect();
|
||||
let mut hours = js_number_from_split_segment(parts.first().copied());
|
||||
let minutes = js_number_from_split_segment(parts.get(1).copied());
|
||||
|
||||
let mut period = "am";
|
||||
if hours >= 12.0 {
|
||||
period = "pm";
|
||||
if hours > 12.0 {
|
||||
hours -= 12.0;
|
||||
}
|
||||
} else if hours == 0.0 {
|
||||
hours = 12.0;
|
||||
}
|
||||
|
||||
let mut hours_str = js_number_to_string(hours);
|
||||
if hours_str.len() == 2 && hours_str.starts_with('0') {
|
||||
hours_str = hours_str[1..].to_string();
|
||||
}
|
||||
|
||||
let minute_part = if no_minutes {
|
||||
String::new()
|
||||
} else {
|
||||
let m = js_number_to_string(minutes);
|
||||
let m = if m.len() >= 2 { m } else { format!("0{m}") };
|
||||
format!(":{m}")
|
||||
};
|
||||
|
||||
format!("{hours_str}{minute_part}{period}")
|
||||
}
|
||||
|
||||
/// `convertTo12HourFormat(...).toLowerCase().replace(" ", "")` from `updateTimetableTimes.ts`.
|
||||
#[wasm_bindgen(js_name = formatTimetableTimeLabel)]
|
||||
pub fn format_timetable_time_label(time: &str, no_minutes: bool) -> String {
|
||||
convert_to_12_hour_format(time, no_minutes)
|
||||
.to_lowercase()
|
||||
.replace(' ', "")
|
||||
}
|
||||
|
||||
/// Formats a `start–end` / `start-end` range label for timetable rows.
|
||||
#[wasm_bindgen(js_name = formatTimetableTimeRange)]
|
||||
pub fn format_timetable_time_range(original: &str) -> Option<String> {
|
||||
let mut parts = original
|
||||
.split(|c| c == '-' || c == '\u{2013}')
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty());
|
||||
let start = parts.next()?;
|
||||
let end = parts.next()?;
|
||||
let start12 = format_timetable_time_label(start, false);
|
||||
let end12 = format_timetable_time_label(end, false);
|
||||
Some(format!("{start12}\u{2013}{end12}"))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Timetable URL/hash checks.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = locationHashIncludesTimetablePage)]
|
||||
pub fn location_hash_includes_timetable_page(location_hash: &str) -> bool {
|
||||
location_hash.contains("page=/timetable")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! User-agent sniffing (`isFirefox` in assessments average).
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(js_name = isFirefoxUserAgent)]
|
||||
pub fn is_firefox_user_agent(user_agent: &str) -> bool {
|
||||
let u = user_agent.to_ascii_lowercase();
|
||||
u.contains("firefox") && !u.contains("seamonkey") && !u.contains("waterfox")
|
||||
}
|
||||
Reference in New Issue
Block a user