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.worker.min.mjs
|
||||||
src/public/resources/pdfjs/pdf.legacy.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
|
# Build
|
||||||
extension.zip
|
extension.zip
|
||||||
build/
|
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"]
|
||||||
@@ -6,6 +6,12 @@
|
|||||||
"browserslist": "> 0.5%, last 2 versions, not dead",
|
"browserslist": "> 0.5%, last 2 versions, not dead",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node scripts/copy-pdfjs-assets.mjs",
|
"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",
|
"autoaudit": "npm audit && npm audit fix && npm run build",
|
||||||
"dev": "cross-env MODE=chrome vite dev",
|
"dev": "cross-env MODE=chrome vite dev",
|
||||||
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
"dev:firefox": "cross-env MODE=firefox vite build --watch",
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
+57
-11
@@ -10,6 +10,12 @@ import * as plugins from "@/plugins";
|
|||||||
import { main } from "@/seqta/main";
|
import { main } from "@/seqta/main";
|
||||||
import { delay } from "./seqta/utils/delay";
|
import { delay } from "./seqta/utils/delay";
|
||||||
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
import { initializeHideSensitiveToggle } from "@/seqta/utils/hideSensitiveToggle";
|
||||||
|
import {
|
||||||
|
childTextHasSeqtaCopyright,
|
||||||
|
initBetterseqtaWasm,
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
titleIsSeqtaLearnOrEngage,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
function registerFetchSeqtaAppLinkListener() {
|
function registerFetchSeqtaAppLinkListener() {
|
||||||
browser.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
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;
|
export let MenuOptionsOpen = false;
|
||||||
|
|
||||||
var IsSEQTAPage = false;
|
var IsSEQTAPage = false;
|
||||||
let hasSEQTAText = 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)
|
// 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]) {
|
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 =
|
hasSEQTAText =
|
||||||
document.childNodes[1].textContent?.includes(
|
typeof childText === "string" &&
|
||||||
"Copyright (c) SEQTA Software",
|
(isBetterseqtaWasmReady()
|
||||||
) ?? false;
|
? childTextHasSeqtaCopyright(childText)
|
||||||
init();
|
: childTextLooksLikeSeqtaCopyright(childText));
|
||||||
|
|
||||||
|
await init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (
|
const titleOk = isBetterseqtaWasmReady()
|
||||||
hasSEQTAText &&
|
? titleIsSeqtaLearnOrEngage(document.title)
|
||||||
(document.title.includes("SEQTA Learn") ||
|
: titleLooksLikeSeqtaLearnOrEngage(document.title);
|
||||||
document.title.includes("SEQTA Engage")) &&
|
|
||||||
!IsSEQTAPage
|
if (hasSEQTAText && titleOk && !IsSEQTAPage) {
|
||||||
) {
|
|
||||||
IsSEQTAPage = true;
|
IsSEQTAPage = true;
|
||||||
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
console.info("[BetterSEQTA+] Verified SEQTA Page");
|
||||||
|
|
||||||
@@ -107,7 +153,7 @@ async function init() {
|
|||||||
plugins.Monofile();
|
plugins.Monofile();
|
||||||
|
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
await plugins.initializePlugins();
|
scheduleDeferredPluginInitialization();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsState.devMode) {
|
if (settingsState.devMode) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"service_worker": "background.ts"
|
"service_worker": "background.ts"
|
||||||
},
|
},
|
||||||
"content_security_policy": {
|
"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": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"web_accessible_resources": [
|
"web_accessible_resources": [
|
||||||
{
|
{
|
||||||
"resources": [
|
"resources": [
|
||||||
|
"assets/*",
|
||||||
"resources/icons/*",
|
"resources/icons/*",
|
||||||
"resources/update-image.webp",
|
"resources/update-image.webp",
|
||||||
"resources/pdfjs/pdf.worker.min.mjs",
|
"resources/pdfjs/pdf.worker.min.mjs",
|
||||||
|
|||||||
@@ -5,9 +5,57 @@ import {
|
|||||||
getPdfjsPageContextUrls,
|
getPdfjsPageContextUrls,
|
||||||
} from "@/lib/pdfjsExtension.ts";
|
} from "@/lib/pdfjsExtension.ts";
|
||||||
import * as pdfjs from "pdfjs-dist";
|
import * as pdfjs from "pdfjs-dist";
|
||||||
|
import {
|
||||||
|
escapeJsForInlineScript,
|
||||||
|
escapeJsSingleQuotedString,
|
||||||
|
extractWeightFromPdfText,
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
isFirefoxUserAgent,
|
||||||
|
parseGradeToPercent,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
ensurePdfjsWorker();
|
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) {
|
export async function initStorage(api: any) {
|
||||||
await api.storage.loaded;
|
await api.storage.loaded;
|
||||||
|
|
||||||
@@ -63,7 +111,7 @@ export const letterToNumber: Record<string, number> = {
|
|||||||
F: 0,
|
F: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseGrade(text: string): number {
|
function parseGradeTs(text: string): number {
|
||||||
const str = text.trim().toUpperCase();
|
const str = text.trim().toUpperCase();
|
||||||
if (str.includes("/")) {
|
if (str.includes("/")) {
|
||||||
const [raw, max] = str.split("/").map((n) => parseFloat(n));
|
const [raw, max] = str.split("/").map((n) => parseFloat(n));
|
||||||
@@ -75,6 +123,17 @@ function parseGrade(text: string): number {
|
|||||||
return letterToNumber[str] ?? 0;
|
return letterToNumber[str] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGrade(text: string): number {
|
||||||
|
if (isBetterseqtaWasmReady()) {
|
||||||
|
try {
|
||||||
|
return parseGradeToPercent(text);
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseGradeTs(text);
|
||||||
|
}
|
||||||
|
|
||||||
function createWeightLabel(
|
function createWeightLabel(
|
||||||
assessmentItem: Element,
|
assessmentItem: Element,
|
||||||
weighting: string | undefined,
|
weighting: string | undefined,
|
||||||
@@ -128,19 +187,14 @@ function createWeightLabel(
|
|||||||
statsContainer.appendChild(weightLabel);
|
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> {
|
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||||
const isBlobUrl = url.startsWith("blob:");
|
const isBlobUrl = url.startsWith("blob:");
|
||||||
|
|
||||||
if (isBlobUrl || isFirefox) {
|
if (isBlobUrl || detectFirefox()) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||||
const escapedUrl = url.replace(/'/g, "\\'");
|
const escapedUrl = escJsSingleQuoted(url);
|
||||||
|
|
||||||
script.textContent = `
|
script.textContent = `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -227,10 +281,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
|||||||
|
|
||||||
export async function extractPDFText(url: string): Promise<string> {
|
export async function extractPDFText(url: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (isFirefox) {
|
if (detectFirefox()) {
|
||||||
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
|
||||||
const escJsSingleQuoted = (s: string) =>
|
|
||||||
s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
||||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||||
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||||
|
|
||||||
@@ -238,10 +290,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
|||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
const escapedUrl = url
|
const escapedUrl = escJsInlineUrl(url);
|
||||||
.replace(/\\/g, "\\\\")
|
|
||||||
.replace(/'/g, "\\'")
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
|
|
||||||
script.textContent = `
|
script.textContent = `
|
||||||
(function() {
|
(function() {
|
||||||
@@ -488,7 +537,7 @@ async function handleWeightings(mark: any, api: any) {
|
|||||||
text = await extractPDFText(pdfUrl);
|
text = await extractPDFText(pdfUrl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (
|
if (
|
||||||
isFirefox &&
|
detectFirefox() &&
|
||||||
(error?.message?.includes("blob") ||
|
(error?.message?.includes("blob") ||
|
||||||
error?.message?.includes("Security") ||
|
error?.message?.includes("Security") ||
|
||||||
error?.message?.includes("CSP"))
|
error?.message?.includes("CSP"))
|
||||||
@@ -500,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 = {
|
||||||
...api.storage.weightings,
|
...api.storage.weightings,
|
||||||
[assessmentID]: match ? match[1] : "N/A",
|
[assessmentID]: weight ?? "N/A",
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
api.storage.weightings = {
|
api.storage.weightings = {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import {
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
isFirefoxUserAgent,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects if the current browser is Firefox
|
* Detects if the current browser is Firefox
|
||||||
@@ -11,6 +15,13 @@ export function isFirefox(): boolean {
|
|||||||
}
|
}
|
||||||
// Fallback: check user agent
|
// Fallback: check user agent
|
||||||
if (typeof navigator !== "undefined") {
|
if (typeof navigator !== "undefined") {
|
||||||
|
if (isBetterseqtaWasmReady()) {
|
||||||
|
try {
|
||||||
|
return isFirefoxUserAgent(navigator.userAgent);
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
return navigator.userAgent.toLowerCase().includes("firefox");
|
return navigator.userAgent.toLowerCase().includes("firefox");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
clearCustomThemeAdaptiveCssVariables,
|
clearCustomThemeAdaptiveCssVariables,
|
||||||
setCustomThemeAdaptiveCssVariables,
|
setCustomThemeAdaptiveCssVariables,
|
||||||
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||||
|
import {
|
||||||
|
decodeBase64,
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
stripDataUrlBase64Payload,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
type ThemeContent = {
|
type ThemeContent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1087,8 +1092,15 @@ export class ThemeManager {
|
|||||||
private stripBase64Prefix(base64String: string): string {
|
private stripBase64Prefix(base64String: string): string {
|
||||||
if (!base64String) return "";
|
if (!base64String) return "";
|
||||||
|
|
||||||
const prefixRegex = /^data:[^;]+;base64,/;
|
|
||||||
try {
|
try {
|
||||||
|
if (isBetterseqtaWasmReady()) {
|
||||||
|
try {
|
||||||
|
return stripDataUrlBase64Payload(base64String);
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const prefixRegex = /^data:[^;]+;base64,/;
|
||||||
return prefixRegex.test(base64String)
|
return prefixRegex.test(base64String)
|
||||||
? base64String.replace(prefixRegex, "")
|
? base64String.replace(prefixRegex, "")
|
||||||
: base64String;
|
: base64String;
|
||||||
@@ -1100,6 +1112,16 @@ export class ThemeManager {
|
|||||||
|
|
||||||
private base64ToBlob(base64: string): Blob {
|
private base64ToBlob(base64: string): Blob {
|
||||||
try {
|
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 byteString = atob(base64);
|
||||||
const ab = new ArrayBuffer(byteString.length);
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
const ia = new Uint8Array(ab);
|
const ia = new Uint8Array(ab);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// Third-party libraries
|
// Third-party libraries
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import { animate, stagger } from "motion";
|
import { animate, stagger } from "motion";
|
||||||
|
|
||||||
// Internal utilities and functions
|
// Internal utilities and functions
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
} from "@/seqta/utils/Loaders/LoadEngageHomePage";
|
||||||
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
import { loadHomePage } from "@/seqta/utils/Loaders/LoadHomePage";
|
||||||
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export async function finishLoad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GetCSSElement(file: string) {
|
export function GetCSSElement(file: string) {
|
||||||
const cssFile = browser.runtime.getURL(file);
|
const cssFile = extensionAssetUrl(file);
|
||||||
const fileref = document.createElement("link");
|
const fileref = document.createElement("link");
|
||||||
fileref.setAttribute("rel", "stylesheet");
|
fileref.setAttribute("rel", "stylesheet");
|
||||||
fileref.setAttribute("type", "text/css");
|
fileref.setAttribute("type", "text/css");
|
||||||
@@ -866,7 +866,7 @@ function InjectCustomIcons() {
|
|||||||
style.innerHTML = `
|
style.innerHTML = `
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'IconFamily';
|
font-family: 'IconFamily';
|
||||||
src: url('${browser.runtime.getURL(IconFamily)}') format('woff');
|
src: url('${extensionAssetUrl(IconFamily)}') format('woff');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}`;
|
}`;
|
||||||
|
|||||||
+7
-2
@@ -1,8 +1,8 @@
|
|||||||
// Third-party libraries
|
// Third-party libraries
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
|
|
||||||
// Internal utilities and functions
|
// Internal utilities and functions
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
// UI and theme management
|
// UI and theme management
|
||||||
import pageState from "@/pageState.js?url";
|
import pageState from "@/pageState.js?url";
|
||||||
@@ -34,7 +34,12 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function injectPageState() {
|
function injectPageState() {
|
||||||
|
const scriptUrl = extensionAssetUrl(pageState);
|
||||||
|
if (!scriptUrl) {
|
||||||
|
console.warn("[BetterSEQTA+] Could not resolve pageState script URL.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mainScript = document.createElement("script");
|
const mainScript = document.createElement("script");
|
||||||
mainScript.src = browser.runtime.getURL(pageState);
|
mainScript.src = scriptUrl;
|
||||||
document.head.appendChild(mainScript);
|
document.head.appendChild(mainScript);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import browser from "webextension-polyfill";
|
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
import { lightenAndPaleColor } from "./lightenAndPaleColor";
|
||||||
@@ -6,6 +5,7 @@ import ColorLuminance from "./ColorLuminance";
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
import { getAdaptiveColour } from "@/seqta/utils/adaptiveThemeColour";
|
||||||
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
import { getCustomThemeAdaptiveCssVariableBindings } from "@/seqta/ui/colors/customThemeAdaptiveBindings";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
import darkLogo from "@/resources/icons/betterseqta-light-full.png";
|
||||||
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
import lightLogo from "@/resources/icons/betterseqta-dark-full.png";
|
||||||
@@ -115,11 +115,11 @@ function applyColorsWith(selectedColor: string) {
|
|||||||
let modeProps = {};
|
let modeProps = {};
|
||||||
modeProps = settingsState.DarkMode
|
modeProps = settingsState.DarkMode
|
||||||
? {
|
? {
|
||||||
"--betterseqta-logo": `url(${browser.runtime.getURL(darkLogo)})`,
|
"--betterseqta-logo": `url(${extensionAssetUrl(darkLogo)})`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
"--better-pale": lightenAndPaleColor(selectedColor),
|
"--better-pale": lightenAndPaleColor(selectedColor),
|
||||||
"--betterseqta-logo": `url(${browser.runtime.getURL(lightLogo)})`,
|
"--betterseqta-logo": `url(${extensionAssetUrl(lightLogo)})`,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (settingsState.DarkMode) {
|
if (settingsState.DarkMode) {
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
import Color from "color";
|
import Color from "color";
|
||||||
|
import {
|
||||||
|
colorCssThresholdDistance,
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
export function GetThresholdOfColor(color: any) {
|
export function GetThresholdOfColor(color: any) {
|
||||||
if (!color) return 0;
|
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;
|
const rgbaRegex = /rgba?\(([^)]+)\)/gi;
|
||||||
|
|
||||||
// Check if the color string is a gradient (linear or radial)
|
if (s.includes("gradient")) {
|
||||||
if (color.includes("gradient")) {
|
const gradientThresholds = [];
|
||||||
let gradientThresholds = [];
|
|
||||||
|
|
||||||
// Find and replace all instances of RGBA in the gradient
|
|
||||||
let match;
|
let match;
|
||||||
while ((match = rgbaRegex.exec(color)) !== null) {
|
while ((match = rgbaRegex.exec(s)) !== null) {
|
||||||
// Extract the individual components (r, g, b, a)
|
|
||||||
const rgbaString = match[1];
|
const rgbaString = match[1];
|
||||||
const [r, g, b] = rgbaString.split(",").map((str) => str.trim());
|
const [r, g, b] = rgbaString.split(",").map((str) => str.trim());
|
||||||
|
|
||||||
// Compute the threshold using your existing algorithm
|
|
||||||
const threshold = Math.sqrt(
|
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);
|
gradientThresholds.push(threshold);
|
||||||
}
|
}
|
||||||
|
if (gradientThresholds.length === 0) {
|
||||||
// Calculate the average threshold
|
return 0;
|
||||||
const averageThreshold =
|
}
|
||||||
|
return (
|
||||||
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
gradientThresholds.reduce((acc, val) => acc + val, 0) /
|
||||||
gradientThresholds.length;
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { animate } from "motion";
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||||
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
import { GetThresholdOfColor } from "@/seqta/ui/colors/getThresholdColour";
|
||||||
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
import { convertTo12HourFormat } from "@/seqta/utils/convertTo12HourFormat";
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
toISODate,
|
toISODate,
|
||||||
weekRangeContaining,
|
weekRangeContaining,
|
||||||
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
} from "@/seqta/utils/Loaders/engageParentTimetable";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
export function updateEngageHomeMenuActive(isHome: boolean): void {
|
||||||
const home = document.getElementById("homebutton");
|
const home = document.getElementById("homebutton");
|
||||||
@@ -128,7 +128,7 @@ function renderEngageDayLessons(): void {
|
|||||||
if (lessons.length === 0) {
|
if (lessons.length === 0) {
|
||||||
dayContainer.innerHTML = `
|
dayContainer.innerHTML = `
|
||||||
<div class="day-empty">
|
<div class="day-empty">
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||||
<p>No lessons for this day.</p>
|
<p>No lessons for this day.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -273,7 +273,7 @@ function processEngageNotices(response: any, labelArray: string[]): void {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No notices for today.";
|
text.innerText = "No notices for today.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -285,7 +285,7 @@ function processEngageNotices(response: any, labelArray: string[]): void {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No notices for today.";
|
text.innerText = "No notices for today.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -713,7 +713,7 @@ function showEngageTimetableError(message: string): void {
|
|||||||
dayContainer.classList.remove("loading");
|
dayContainer.classList.remove("loading");
|
||||||
dayContainer.innerHTML = `
|
dayContainer.innerHTML = `
|
||||||
<div class="day-empty">
|
<div class="day-empty">
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||||
<p>${message}</p>
|
<p>${message}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -724,7 +724,7 @@ function showEngageNoticesSectionError(message: string): void {
|
|||||||
noticeContainer.classList.remove("loading");
|
noticeContainer.classList.remove("loading");
|
||||||
noticeContainer.innerHTML = `
|
noticeContainer.innerHTML = `
|
||||||
<div class="day-empty">
|
<div class="day-empty">
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" alt="" />
|
<img src="${extensionAssetUrl(LogoLight)}" alt="" />
|
||||||
<p>${message}</p>
|
<p>${message}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { animate, stagger } from "motion";
|
import { animate, stagger } from "motion";
|
||||||
import browser from "webextension-polyfill";
|
|
||||||
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
import LogoLight from "@/resources/icons/betterseqta-light-icon.png";
|
||||||
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
import assessmentsicon from "@/seqta/icons/assessmentsIcon";
|
||||||
import coursesicon from "@/seqta/icons/coursesIcon";
|
import coursesicon from "@/seqta/icons/coursesIcon";
|
||||||
@@ -13,6 +12,7 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement";
|
|||||||
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments";
|
||||||
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent";
|
||||||
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
let LessonInterval: any;
|
let LessonInterval: any;
|
||||||
let currentSelectedDate = new Date();
|
let currentSelectedDate = new Date();
|
||||||
@@ -155,7 +155,7 @@ export async function loadHomePage() {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No notices available.";
|
text.innerText = "No notices available.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -304,7 +304,7 @@ function processNotices(response: any, labelArray: string[]) {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No notices for today.";
|
text.innerText = "No notices for today.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -316,7 +316,7 @@ function processNotices(response: any, labelArray: string[]) {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No notices for today.";
|
text.innerText = "No notices for today.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -735,7 +735,7 @@ function callHomeTimetable(date: string, change?: any) {
|
|||||||
const dummyDay = document.createElement("div");
|
const dummyDay = document.createElement("div");
|
||||||
dummyDay.classList.add("day-empty");
|
dummyDay.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLight);
|
img.src = extensionAssetUrl(LogoLight);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No lessons available.";
|
text.innerText = "No lessons available.";
|
||||||
dummyDay.append(img, text);
|
dummyDay.append(img, text);
|
||||||
@@ -987,7 +987,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) {
|
|||||||
if (assessments.length === 0) {
|
if (assessments.length === 0) {
|
||||||
upcomingitemcontainer!.innerHTML = `
|
upcomingitemcontainer!.innerHTML = `
|
||||||
<div class="day-empty">
|
<div class="day-empty">
|
||||||
<img src="${browser.runtime.getURL(LogoLight)}" />
|
<img src="${extensionAssetUrl(LogoLight)}" />
|
||||||
<p>No assessments available.</p>
|
<p>No assessments available.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { settingsState } from "./listeners/SettingsState";
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png";
|
import LogoLightOutline from "@/resources/icons/betterseqta-light-outline.png";
|
||||||
import { animate, stagger } from "motion";
|
import { animate, stagger } from "motion";
|
||||||
|
import { extensionAssetUrl } from "@/seqta/utils/extensionAsset";
|
||||||
|
|
||||||
export async function SendNewsPage() {
|
export async function SendNewsPage() {
|
||||||
console.info("[BetterSEQTA+] Started Loading News Page");
|
console.info("[BetterSEQTA+] Started Loading News Page");
|
||||||
@@ -58,7 +59,7 @@ export async function SendNewsPage() {
|
|||||||
const emptyState = document.createElement("div");
|
const emptyState = document.createElement("div");
|
||||||
emptyState.classList.add("day-empty");
|
emptyState.classList.add("day-empty");
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = browser.runtime.getURL(LogoLightOutline);
|
img.src = extensionAssetUrl(LogoLightOutline);
|
||||||
const text = document.createElement("p");
|
const text = document.createElement("p");
|
||||||
text.innerText = "No news articles available right now.";
|
text.innerText = "No news articles available right now.";
|
||||||
emptyState.append(img, text);
|
emptyState.append(img, text);
|
||||||
@@ -79,7 +80,7 @@ export async function SendNewsPage() {
|
|||||||
|
|
||||||
if (article.urlToImage == "null" || article.urlToImage == null) {
|
if (article.urlToImage == "null" || article.urlToImage == null) {
|
||||||
articleimage.style.cssText = `
|
articleimage.style.cssText = `
|
||||||
background-image: url(${browser.runtime.getURL(LogoLightOutline)});
|
background-image: url(${extensionAssetUrl(LogoLightOutline)});
|
||||||
width: 20%;
|
width: 20%;
|
||||||
margin: 0 7.5%;
|
margin: 0 7.5%;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
import { getUserInfo } from "@/seqta/ui/AddBetterSEQTAElements";
|
||||||
|
import {
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
normalizeSeqtaSubjectHexColour,
|
||||||
|
parseSeqtaCoursesAssessmentsPageJson,
|
||||||
|
} from "@/wasm/init";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the current page from window.location.hash.
|
* 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 {
|
function parsePageContext(): { programme: number; metaclass: number } | null {
|
||||||
const hash = window.location.hash || "";
|
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+)/);
|
const match = hash.match(/[?&]page=\/(courses|assessments)\/(?:[^/]+\/)?(\d+):(\d+)/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const programme = parseInt(match[2], 10);
|
const programme = parseInt(match[2], 10);
|
||||||
@@ -112,6 +135,14 @@ export async function getAdaptiveColour(): Promise<string | null> {
|
|||||||
const colour = await getSubjectColour(subjectCode, userId);
|
const colour = await getSubjectColour(subjectCode, userId);
|
||||||
if (!colour || typeof colour !== "string") return null;
|
if (!colour || typeof colour !== "string") return null;
|
||||||
|
|
||||||
|
if (isBetterseqtaWasmReady()) {
|
||||||
|
try {
|
||||||
|
const normalized = normalizeSeqtaSubjectHexColour(colour);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
// Basic hex validation
|
// Basic hex validation
|
||||||
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(colour)) return colour;
|
||||||
if (/^[0-9A-Fa-f]{6}$/.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 byteCharacters = atob(base64);
|
||||||
const byteArrays: Uint8Array[] = [];
|
const byteArrays: Uint8Array[] = [];
|
||||||
|
|
||||||
@@ -14,4 +16,15 @@ const base64ToBlob = (base64: string, contentType: string = ""): Blob => {
|
|||||||
return new Blob(byteArrays, { type: contentType });
|
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;
|
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) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
@@ -8,4 +10,21 @@ export const blobToBase64 = (blob: Blob) => {
|
|||||||
reader.onerror = reject;
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(blob);
|
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,
|
time: string,
|
||||||
noMinutes: boolean = false,
|
noMinutes: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
@@ -19,3 +24,18 @@ export function convertTo12HourFormat(
|
|||||||
|
|
||||||
return `${hoursStr}${noMinutes ? "" : `:${minutes.toString().padStart(2, "0")}`}${period}`;
|
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"`.
|
* Learn-style hash routes on Engage: `#?page=/home` → `"home"`.
|
||||||
* Falls back to the legacy path segment used by classic Learn routing.
|
* Falls back to the legacy path segment used by classic Learn routing.
|
||||||
*/
|
*/
|
||||||
export function getEngageRoutePage(): string | undefined {
|
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(/^#/, "");
|
const hash = window.location.hash.replace(/^#/, "");
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const qs = hash.startsWith("?") ? hash : `?${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) {
|
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 base64Index = base64.indexOf(",") + 1;
|
||||||
const imageBase64 = base64.substring(base64Index);
|
const imageBase64 = base64.substring(base64Index);
|
||||||
|
|
||||||
// Convert base64 to blob
|
|
||||||
const byteCharacters = atob(imageBase64);
|
const byteCharacters = atob(imageBase64);
|
||||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||||
for (let i = 0; i < byteCharacters.length; i++) {
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
@@ -12,8 +29,5 @@ export function base64toblobURL(base64: string) {
|
|||||||
const byteArray = new Uint8Array(byteNumbers);
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
const blob = new Blob([byteArray], { type: "image/png" });
|
const blob = new Blob([byteArray], { type: "image/png" });
|
||||||
|
|
||||||
// Convert blob to blob URL
|
return URL.createObjectURL(blob);
|
||||||
const imageUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
return imageUrl;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import { isBetterseqtaWasmReady, titleIsSeqtaEngage } from "@/wasm/init";
|
||||||
|
|
||||||
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
|
/** SEQTA Engage (React) uses a different shell from classic SEQTA Learn. */
|
||||||
export function isSeqtaEngageExperience(): boolean {
|
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");
|
return document.title.includes("SEQTA Engage");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,41 @@
|
|||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
import {
|
||||||
|
formatTimetableTimeLabel,
|
||||||
|
formatTimetableTimeRange,
|
||||||
|
isBetterseqtaWasmReady,
|
||||||
|
locationHashIncludesTimetablePage,
|
||||||
|
} from "@/wasm/init";
|
||||||
import { convertTo12HourFormat } from "./convertTo12HourFormat";
|
import { convertTo12HourFormat } from "./convertTo12HourFormat";
|
||||||
import { waitForElm } from "./waitForElm";
|
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 timetableObserver: MutationObserver | null = null;
|
||||||
let isOnTimetablePage = false;
|
let isOnTimetablePage = false;
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
@@ -19,9 +53,7 @@ function updateTimeElements(): void {
|
|||||||
const original = el.dataset.original;
|
const original = el.dataset.original;
|
||||||
if (!original) return;
|
if (!original) return;
|
||||||
|
|
||||||
el.textContent = convertTo12HourFormat(original, true)
|
el.textContent = timetableLabel12(original, true);
|
||||||
.toLowerCase()
|
|
||||||
.replace(" ", "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const entryTimes = timetablePage.querySelectorAll<HTMLElement>(".entry .times");
|
const entryTimes = timetablePage.querySelectorAll<HTMLElement>(".entry .times");
|
||||||
@@ -30,30 +62,30 @@ function updateTimeElements(): void {
|
|||||||
const original = el.dataset.original || "";
|
const original = el.dataset.original || "";
|
||||||
if (!original.includes("–") && !original.includes("-")) return;
|
if (!original.includes("–") && !original.includes("-")) return;
|
||||||
|
|
||||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
const ranged = timetableRange12(original);
|
||||||
if (!start || !end) return;
|
if (ranged) el.textContent = ranged;
|
||||||
|
|
||||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
|
||||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
|
||||||
el.textContent = `${start12}–${end12}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const quickbarTimes = document.querySelectorAll<HTMLElement>(".quickbar .meta .times");
|
const quickbarTimes = document.querySelectorAll<HTMLElement>(
|
||||||
|
".quickbar .meta .times",
|
||||||
|
);
|
||||||
quickbarTimes.forEach((el) => {
|
quickbarTimes.forEach((el) => {
|
||||||
if (!el.dataset.original) el.dataset.original = el.textContent || "";
|
if (!el.dataset.original) el.dataset.original = el.textContent || "";
|
||||||
const original = el.dataset.original || "";
|
const original = el.dataset.original || "";
|
||||||
|
|
||||||
if (!original.includes("–") && !original.includes("-")) return;
|
const ranged = timetableRange12(original);
|
||||||
const [start, end] = original.split(/[-–]/).map((p) => p.trim());
|
if (ranged) el.textContent = ranged;
|
||||||
|
|
||||||
if (!start || !end) return;
|
|
||||||
const start12 = convertTo12HourFormat(start).toLowerCase().replace(" ", "");
|
|
||||||
const end12 = convertTo12HourFormat(end).toLowerCase().replace(" ", "");
|
|
||||||
el.textContent = `${start12}–${end12}`;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkIfOnTimetablePage(): boolean {
|
function checkIfOnTimetablePage(): boolean {
|
||||||
|
if (isBetterseqtaWasmReady()) {
|
||||||
|
try {
|
||||||
|
return locationHashIncludesTimetablePage(window.location.hash);
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
return window.location.hash.includes("page=/timetable");
|
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";
|
const useMillion = mode.toLowerCase() !== "firefox";
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
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: [
|
plugins: [
|
||||||
base64Loader,
|
base64Loader,
|
||||||
InlineWorkerPlugin(),
|
InlineWorkerPlugin(),
|
||||||
|
|||||||
@@ -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