diff --git a/.gitignore b/.gitignore index 47ffcf73..d0b58298 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..7dd8c2cf --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..c9a251a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["wasm/betterseqta-wasm"] diff --git a/package.json b/package.json index 27826d94..b8baced0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/rm-wasm-pkg-gitignore.mjs b/scripts/rm-wasm-pkg-gitignore.mjs new file mode 100644 index 00000000..8a5ac1ed --- /dev/null +++ b/scripts/rm-wasm-pkg-gitignore.mjs @@ -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 */ +} diff --git a/src/SEQTA.ts b/src/SEQTA.ts index 60a6b6a7..b535bf4f 100644 --- a/src/SEQTA.ts +++ b/src/SEQTA.ts @@ -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; } }); -} \ No newline at end of file +} diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index b4bfe4e4..4ca49263 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -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", diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index d833cc98..1e985e31 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -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; @@ -63,7 +111,7 @@ export const letterToNumber: Record = { 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)); @@ -75,6 +123,17 @@ 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, @@ -128,19 +187,14 @@ function createWeightLabel( 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 { 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() { @@ -227,10 +281,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { export async function extractPDFText(url: string): Promise { try { - if (isFirefox) { + if (detectFirefox()) { const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls(); - const escJsSingleQuoted = (s: string) => - s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const pdfLibInj = escJsSingleQuoted(pdfLibUrl); const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl); @@ -238,10 +290,7 @@ export async function extractPDFText(url: string): Promise { 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() { @@ -488,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")) @@ -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, - [assessmentID]: match ? match[1] : "N/A", + [assessmentID]: weight ?? "N/A", }; } catch (error: any) { api.storage.weightings = { diff --git a/src/plugins/built-in/globalSearch/src/utils/browserDetection.ts b/src/plugins/built-in/globalSearch/src/utils/browserDetection.ts index 7ad87c9f..04ab724a 100644 --- a/src/plugins/built-in/globalSearch/src/utils/browserDetection.ts +++ b/src/plugins/built-in/globalSearch/src/utils/browserDetection.ts @@ -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; diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 0faf1449..3731b624 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -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); diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 4892c7df..0ba1fd10 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -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; }`; diff --git a/src/seqta/main.ts b/src/seqta/main.ts index f9aa49cb..33c06c80 100644 --- a/src/seqta/main.ts +++ b/src/seqta/main.ts @@ -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); } diff --git a/src/seqta/ui/colors/Manager.ts b/src/seqta/ui/colors/Manager.ts index 71740da9..af2d0c06 100644 --- a/src/seqta/ui/colors/Manager.ts +++ b/src/seqta/ui/colors/Manager.ts @@ -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) { diff --git a/src/seqta/ui/colors/getThresholdColour.ts b/src/seqta/ui/colors/getThresholdColour.ts index b105169c..548712fe 100644 --- a/src/seqta/ui/colors/getThresholdColour.ts +++ b/src/seqta/ui/colors/getThresholdColour.ts @@ -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); } diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts index ece4e788..101a50ca 100644 --- a/src/seqta/utils/Loaders/LoadEngageHomePage.ts +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -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,6 +16,7 @@ import { toISODate, weekRangeContaining, } from "@/seqta/utils/Loaders/engageParentTimetable"; +import { extensionAssetUrl } from "@/seqta/utils/extensionAsset"; export function updateEngageHomeMenuActive(isHome: boolean): void { const home = document.getElementById("homebutton"); @@ -128,7 +128,7 @@ function renderEngageDayLessons(): void { if (lessons.length === 0) { dayContainer.innerHTML = `
- +

No lessons for this day.

`; } else { @@ -273,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); @@ -285,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); @@ -713,7 +713,7 @@ function showEngageTimetableError(message: string): void { dayContainer.classList.remove("loading"); dayContainer.innerHTML = `
- +

${message}

`; } @@ -724,7 +724,7 @@ function showEngageNoticesSectionError(message: string): void { noticeContainer.classList.remove("loading"); noticeContainer.innerHTML = `
- +

${message}

`; } diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index e590e097..b2663eef 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -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,6 +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 { extensionAssetUrl } from "@/seqta/utils/extensionAsset"; let LessonInterval: any; let currentSelectedDate = new Date(); @@ -155,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); @@ -304,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); @@ -316,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); @@ -735,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); @@ -987,7 +987,7 @@ async function CreateUpcomingSection(assessments: any, activeSubjects: any) { if (assessments.length === 0) { upcomingitemcontainer!.innerHTML = `
- +

No assessments available.

`; } diff --git a/src/seqta/utils/SendNewsPage.ts b/src/seqta/utils/SendNewsPage.ts index 3b1e9c66..ed923b0f 100644 --- a/src/seqta/utils/SendNewsPage.ts +++ b/src/seqta/utils/SendNewsPage.ts @@ -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%; `; diff --git a/src/seqta/utils/adaptiveThemeColour.ts b/src/seqta/utils/adaptiveThemeColour.ts index b5ab3872..061c36b6 100644 --- a/src/seqta/utils/adaptiveThemeColour.ts +++ b/src/seqta/utils/adaptiveThemeColour.ts @@ -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 { 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}`; diff --git a/src/seqta/utils/base64ToBlob.ts b/src/seqta/utils/base64ToBlob.ts index c61b7d2d..c1a3fd44 100644 --- a/src/seqta/utils/base64ToBlob.ts +++ b/src/seqta/utils/base64ToBlob.ts @@ -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; diff --git a/src/seqta/utils/blobToBase64.ts b/src/seqta/utils/blobToBase64.ts index cafd59e3..0a946e96 100644 --- a/src/seqta/utils/blobToBase64.ts +++ b/src/seqta/utils/blobToBase64.ts @@ -1,4 +1,6 @@ -export const blobToBase64 = (blob: Blob) => { +import { encodeDataUrl, isBetterseqtaWasmReady } from "@/wasm/init"; + +function readAsDataUrl(blob: Blob): Promise { return new Promise((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); + })(); }; diff --git a/src/seqta/utils/convertTo12HourFormat.ts b/src/seqta/utils/convertTo12HourFormat.ts index 691bea0c..a845b579 100644 --- a/src/seqta/utils/convertTo12HourFormat.ts +++ b/src/seqta/utils/convertTo12HourFormat.ts @@ -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); + } +} diff --git a/src/seqta/utils/engageRoute.ts b/src/seqta/utils/engageRoute.ts index 38fb1cc0..9ace09be 100644 --- a/src/seqta/utils/engageRoute.ts +++ b/src/seqta/utils/engageRoute.ts @@ -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}`; diff --git a/src/seqta/utils/extensionAsset.ts b/src/seqta/utils/extensionAsset.ts new file mode 100644 index 00000000..209b237a --- /dev/null +++ b/src/seqta/utils/extensionAsset.ts @@ -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; +} + diff --git a/src/seqta/utils/imageConversions.ts b/src/seqta/utils/imageConversions.ts index d8233490..4e57e3d9 100644 --- a/src/seqta/utils/imageConversions.ts +++ b/src/seqta/utils/imageConversions.ts @@ -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); } diff --git a/src/seqta/utils/isSeqtaEngage.ts b/src/seqta/utils/isSeqtaEngage.ts index 727ec982..a3d9316d 100644 --- a/src/seqta/utils/isSeqtaEngage.ts +++ b/src/seqta/utils/isSeqtaEngage.ts @@ -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"); } diff --git a/src/seqta/utils/updateTimetableTimes.ts b/src/seqta/utils/updateTimetableTimes.ts index e4afc153..e05ec144 100644 --- a/src/seqta/utils/updateTimetableTimes.ts +++ b/src/seqta/utils/updateTimetableTimes.ts @@ -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(".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(".quickbar .meta .times"); + const quickbarTimes = document.querySelectorAll( + ".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"); } diff --git a/src/wasm/init.ts b/src/wasm/init.ts new file mode 100644 index 00000000..60677230 --- /dev/null +++ b/src/wasm/init.ts @@ -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 | null = null; + +export async function initBetterseqtaWasm(): Promise { + 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, +}; diff --git a/vite.config.ts b/vite.config.ts index 13d42b23..4eff4d7b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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(), diff --git a/wasm/betterseqta-wasm/Cargo.toml b/wasm/betterseqta-wasm/Cargo.toml new file mode 100644 index 00000000..653fe629 --- /dev/null +++ b/wasm/betterseqta-wasm/Cargo.toml @@ -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" diff --git a/wasm/betterseqta-wasm/src/base64_api.rs b/wasm/betterseqta-wasm/src/base64_api.rs new file mode 100644 index 00000000..efae8baf --- /dev/null +++ b/wasm/betterseqta-wasm/src/base64_api.rs @@ -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 { + 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() +} diff --git a/wasm/betterseqta-wasm/src/engage.rs b/wasm/betterseqta-wasm/src/engage.rs new file mode 100644 index 00000000..d5dd3ad1 --- /dev/null +++ b/wasm/betterseqta-wasm/src/engage.rs @@ -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 { + 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 { + 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 { + full_href.split('/').nth(4).map(std::string::ToString::to_string) +} diff --git a/wasm/betterseqta-wasm/src/escape.rs b/wasm/betterseqta-wasm/src/escape.rs new file mode 100644 index 00000000..e51e8d31 --- /dev/null +++ b/wasm/betterseqta-wasm/src/escape.rs @@ -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('"', "\\\"") +} diff --git a/wasm/betterseqta-wasm/src/grades.rs b/wasm/betterseqta-wasm/src/grades.rs new file mode 100644 index 00000000..6129505d --- /dev/null +++ b/wasm/betterseqta-wasm/src/grades.rs @@ -0,0 +1,46 @@ +//! Grade parsing (`parseGrade` in `assessmentsAverage/utils.ts`). + +use wasm_bindgen::prelude::*; + +fn letter_grade_percent(s: &str) -> Option { + 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::().ok()); + let max = parts.next().and_then(|p| p.parse::().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::().unwrap_or(0.0); + } + letter_grade_percent(&str).unwrap_or(0.0) +} diff --git a/wasm/betterseqta-wasm/src/hex_colour.rs b/wasm/betterseqta-wasm/src/hex_colour.rs new file mode 100644 index 00000000..37987e75 --- /dev/null +++ b/wasm/betterseqta-wasm/src/hex_colour.rs @@ -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 = 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 = 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 { + 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 +} diff --git a/wasm/betterseqta-wasm/src/lib.rs b/wasm/betterseqta-wasm/src/lib.rs new file mode 100644 index 00000000..eba73a3f --- /dev/null +++ b/wasm/betterseqta-wasm/src/lib.rs @@ -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); + } +} diff --git a/wasm/betterseqta-wasm/src/page_context.rs b/wasm/betterseqta-wasm/src/page_context.rs new file mode 100644 index 00000000..b1645214 --- /dev/null +++ b/wasm/betterseqta-wasm/src/page_context.rs @@ -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 = 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 { + 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}}}"# + )) +} diff --git a/wasm/betterseqta-wasm/src/pdf_weight.rs b/wasm/betterseqta-wasm/src/pdf_weight.rs new file mode 100644 index 00000000..234cbfcd --- /dev/null +++ b/wasm/betterseqta-wasm/src/pdf_weight.rs @@ -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 { + let lower: Vec = 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 +} diff --git a/wasm/betterseqta-wasm/src/seqta.rs b/wasm/betterseqta-wasm/src/seqta.rs new file mode 100644 index 00000000..8fe92807 --- /dev/null +++ b/wasm/betterseqta-wasm/src/seqta.rs @@ -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") +} diff --git a/wasm/betterseqta-wasm/src/threshold.rs b/wasm/betterseqta-wasm/src/threshold.rs new file mode 100644 index 00000000..963e487d --- /dev/null +++ b/wasm/betterseqta-wasm/src/threshold.rs @@ -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 = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"(?i)rgba?\(([^)]+)\)").expect("regex")) +} + +fn parse_js_int_channel(s: &str) -> f64 { + s.trim().parse::().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 { + 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::() / 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, + } +} diff --git a/wasm/betterseqta-wasm/src/time_format.rs b/wasm/betterseqta-wasm/src/time_format.rs new file mode 100644 index 00000000..acccf7e2 --- /dev/null +++ b/wasm/betterseqta-wasm/src/time_format.rs @@ -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::().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 { + 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}")) +} diff --git a/wasm/betterseqta-wasm/src/timetable_nav.rs b/wasm/betterseqta-wasm/src/timetable_nav.rs new file mode 100644 index 00000000..beca07e8 --- /dev/null +++ b/wasm/betterseqta-wasm/src/timetable_nav.rs @@ -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") +} diff --git a/wasm/betterseqta-wasm/src/user_agent.rs b/wasm/betterseqta-wasm/src/user_agent.rs new file mode 100644 index 00000000..f70a173a --- /dev/null +++ b/wasm/betterseqta-wasm/src/user_agent.rs @@ -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") +}