Compare commits

..

1 Commits

Author SHA1 Message Date
AdenMGB aa1e1a925e refac: rewrite a lil in rust 2026-05-04 17:38:49 +09:30
59 changed files with 1758 additions and 1970 deletions
+5
View File
@@ -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
View File
@@ -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",
]
+3
View File
@@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["wasm/betterseqta-wasm"]
+7 -1
View File
@@ -1,11 +1,17 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.6.5", "version": "3.6.4",
"type": "module", "type": "module",
"description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!", "description": "Enhance SEQTA Learn's usability and aesthetics! A fork of BetterSEQTA to continue development and add heaps more features!",
"browserslist": "> 0.5%, last 2 versions, not dead", "browserslist": "> 0.5%, last 2 versions, not dead",
"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",
+10
View File
@@ -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
View File
@@ -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) {
+3 -30
View File
@@ -9,21 +9,6 @@ import {
runCloudSettingsPoll, runCloudSettingsPoll,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
/**
* Session-only dev-mode override of the content API base.
*
* Stored in a module-level variable (not `chrome.storage`) so it is wiped
* automatically when the browser/service-worker process restarts. Content
* scripts re-sync this on every page load via `setDevApiBase` so the value
* survives transient service-worker terminations within the same browser
* session.
*/
const DEFAULT_API_BASE = "https://betterseqta.org";
let DEV_API_BASE: string | null = null;
function apiBase(): string {
return DEV_API_BASE ?? DEFAULT_API_BASE;
}
function reloadSeqtaPages() { function reloadSeqtaPages() {
const result = browser.tabs.query({}); const result = browser.tabs.query({});
function open(tabs: any) { function open(tabs: any) {
@@ -44,7 +29,7 @@ type MessageSender = { (response?: unknown): void };
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
const { token } = request; const { token } = request;
const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -72,7 +57,7 @@ function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boo
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers })
.then((r) => r.json()) .then((r) => r.json())
.then(sendResponse) .then(sendResponse)
.catch((err) => { .catch((err) => {
@@ -298,7 +283,7 @@ function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean
return false; return false;
} }
const isFavorite = action === "favorite"; const isFavorite = action === "favorite";
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, {
method: isFavorite ? "POST" : "DELETE", method: isFavorite ? "POST" : "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
@@ -325,19 +310,8 @@ function isSeqtaOrigin(origin: string): boolean {
} }
} }
function handleSetDevApiBase(request: any): boolean {
const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, "");
} else {
DEV_API_BASE = null;
}
return false;
}
const MESSAGE_HANDLERS: Record<string, MessageHandler> = { const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
reloadTabs: () => reloadSeqtaPages(), reloadTabs: () => reloadSeqtaPages(),
setDevApiBase: handleSetDevApiBase,
extensionPages: (req) => { extensionPages: (req) => {
browser.tabs.query({}).then((tabs) => { browser.tabs.query({}).then((tabs) => {
for (const tab of tabs) { for (const tab of tabs) {
@@ -495,7 +469,6 @@ function getDefaultValues(): SettingsState {
adaptiveThemeColour: false, adaptiveThemeColour: false,
adaptiveThemeGradient: false, adaptiveThemeGradient: false,
adaptiveThemeColourTransition: true, adaptiveThemeColourTransition: true,
themeOfTheMonthDisabled: false,
autoCloudSettingsSync: true, autoCloudSettingsSync: true,
}; };
} }
+24 -193
View File
@@ -3726,150 +3726,6 @@ div.day-empty {
color: var(--text-primary); color: var(--text-primary);
} }
.themeOfTheMonthCard {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 48;
width: min(360px, calc(100vw - 36px));
overflow: visible;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent);
border-radius: 20px;
background: var(--background-primary);
color: var(--text-primary);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
animation: themeOfTheMonthCardIn 0.24s ease-out;
}
.themeOfTheMonthCard::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
border-radius: inherit;
background: inherit;
}
.themeOfTheMonthCardClosing {
pointer-events: none;
animation: themeOfTheMonthCardOut 0.18s ease-in forwards;
}
.themeOfTheMonthCardClose {
position: absolute !important;
top: 4px !important;
right: 4px !important;
z-index: 2;
width: 32px;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer;
font-size: 1.35rem;
line-height: 1;
}
.themeOfTheMonthCardImage {
display: block;
width: 100%;
height: 150px;
margin: 0;
border-radius: 20px 20px 0 0;
object-fit: cover;
}
.themeOfTheMonthCardBody {
padding: 14px 16px 16px;
}
.themeOfTheMonthCardEyebrow {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--better-pri, #6366f1) 82%, var(--text-primary) 18%);
}
.themeOfTheMonthCard h2 {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.themeOfTheMonthCardDescription {
display: -webkit-box;
margin: 8px 0 14px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
font-size: 0.92rem;
line-height: 1.45;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
}
.themeOfTheMonthCardActions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.themeOfTheMonthCardPrimary,
.themeOfTheMonthCardSecondary {
appearance: none;
border: none;
cursor: pointer;
border-radius: 9999px;
padding: 0.58rem 0.9rem;
font-size: 0.86rem;
font-weight: 700;
transition: transform 0.15s ease, filter 0.15s ease, background 0.15s ease;
}
.themeOfTheMonthCardPrimary {
background: var(--better-pri, #6366f1);
color: white;
}
.themeOfTheMonthCardSecondary {
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-primary);
}
.themeOfTheMonthCardPrimary:hover,
.themeOfTheMonthCardSecondary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.themeOfTheMonthCardPrimary:active,
.themeOfTheMonthCardSecondary:active {
transform: translateY(0);
}
@keyframes themeOfTheMonthCardIn {
from {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes themeOfTheMonthCardOut {
to {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
}
@media (max-width: 900px) {
.themeOfTheMonthCard {
z-index: 2147483645;
}
}
.bsplus-theme-highlight {
animation: bsplusThemeHighlightPulse 1.4s ease-in-out 2;
}
@keyframes bsplusThemeHighlightPulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--better-pri, #6366f1) 0%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--better-pri, #6366f1) 60%, transparent);
}
}
.popup-media-fullscreenable { .popup-media-fullscreenable {
cursor: pointer; cursor: pointer;
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
@@ -4519,63 +4375,38 @@ h2.home-subtitle {
.bsplus-toast { .bsplus-toast {
position: fixed; position: fixed;
right: max(18px, env(safe-area-inset-right)); bottom: 24px;
bottom: max(18px, env(safe-area-inset-bottom)); right: 24px;
z-index: 10000; z-index: 10000;
width: min(360px, calc(100vw - 36px)); display: flex;
padding: 14px 16px 16px; align-items: flex-start;
border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); gap: 12px;
border-radius: 20px; max-width: 380px;
background: var(--background-primary, #fff); padding: 16px 18px;
border-radius: 12px;
background: var(--background-secondary, #fff);
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.45; line-height: 1.5;
} }
.bsplus-toast-eyebrow { .bsplus-toast-content p {
margin: 0 0 6px !important; margin: 6px 0 0;
font-size: 0.72rem !important; opacity: 0.8;
font-weight: 700; font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, #ea580c 82%, var(--text-primary) 18%);
opacity: 1 !important;
}
.dark .bsplus-toast-eyebrow {
color: color-mix(in srgb, #fb923c 82%, var(--text-primary) 18%);
}
.bsplus-toast-content strong {
display: block;
padding-right: 34px;
font-size: 1.2rem;
line-height: 1.2;
}
.bsplus-toast-content p:not(.bsplus-toast-eyebrow) {
display: -webkit-box;
margin: 8px 0 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
color: color-mix(in srgb, var(--text-primary) 78%, transparent);
font-size: 0.92rem;
line-height: 1.45;
} }
.bsplus-toast-close { .bsplus-toast-close {
position: absolute !important; flex-shrink: 0;
top: 4px !important; background: none;
right: 4px !important; border: none;
z-index: 2; color: var(--text-primary, #1a1a1a);
width: 32px; font-size: 1.3rem;
height: 32px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 16px !important;
background: rgba(0, 0, 0, 0.42);
color: white;
cursor: pointer; cursor: pointer;
font-size: 1.35rem; padding: 0 2px;
line-height: 1; line-height: 1;
transition: filter 0.15s ease; opacity: 0.5;
transition: opacity 0.15s;
} }
.bsplus-toast-close:hover { .bsplus-toast-close:hover {
filter: brightness(1.08); opacity: 1;
} }
@@ -15,34 +15,8 @@
import CloudHeader from "@/interface/components/store/CloudHeader.svelte" import CloudHeader from "@/interface/components/store/CloudHeader.svelte"
import { cloudAuth } from "@/seqta/utils/CloudAuth" import { cloudAuth } from "@/seqta/utils/CloudAuth"
import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { showThemeOfTheMonthPopupNow } from "@/seqta/utils/Openers/OpenThemeOfTheMonthPopup"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride())
function applyDevApiBase() {
const trimmed = devApiBaseInput.trim()
if (trimmed === "") {
setApiBase(null)
devApiBaseActive = null
return
}
if (!/^https?:\/\//.test(trimmed)) {
alert("Please enter a full URL starting with http:// or https://")
return
}
setApiBase(trimmed)
devApiBaseActive = trimmed.replace(/\/$/, "")
}
function clearDevApiBase() {
devApiBaseInput = ""
setApiBase(null)
devApiBaseActive = null
}
import { getAllPluginSettings } from "@/plugins" import { getAllPluginSettings } from "@/plugins"
import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types" import type { BooleanSetting, StringSetting, NumberSetting, SelectSetting, ButtonSetting, HotkeySetting, ComponentSetting } from "@/plugins/core/types"
@@ -509,22 +483,6 @@
/> />
</div> </div>
</div> </div>
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">Show Theme of the Month</h2>
<p class="text-xs">Fetch and show the current month's popup now (ignores dismissed state)</p>
</div>
<div>
<Button
onClick={async () => {
closeExtensionPopup();
await new Promise((resolve) => setTimeout(resolve, 100));
await showThemeOfTheMonthPopupNow();
}}
text="Show Now"
/>
</div>
</div>
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">Export cloud settings JSON</h2> <h2 class="text-sm font-bold">Export cloud settings JSON</h2>
@@ -534,31 +492,6 @@
<Button onClick={exportCloudSettingsJsonToFile} text="Export to file" /> <Button onClick={exportCloudSettingsJsonToFile} text="Export to file" />
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 px-4 py-3">
<div class="flex justify-between items-start gap-3">
<div class="pr-4">
<h2 class="text-sm font-bold">API Base URL (session only)</h2>
<p class="text-xs">Override the content API host for this browser session. Cleared on restart. Affects themes, theme of the month, and other server-driven content.</p>
{#if devApiBaseActive}
<p class="text-xs mt-1 text-amber-600 dark:text-amber-400">
Override active: <span class="font-mono">{devApiBaseActive}</span>
</p>
{/if}
</div>
</div>
<div class="flex gap-2 items-center">
<input
type="text"
placeholder="https://betterseqta.org"
bind:value={devApiBaseInput}
class="flex-1 px-2 py-1 text-xs rounded border bg-white dark:bg-zinc-800 border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100"
/>
<Button onClick={applyDevApiBase} text="Apply" />
{#if devApiBaseActive}
<Button onClick={clearDevApiBase} text="Clear" />
{/if}
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
-27
View File
@@ -18,7 +18,6 @@
import Backgrounds from '../components/store/Backgrounds.svelte' import Backgrounds from '../components/store/Backgrounds.svelte'
import { cloudAuth } from '@/seqta/utils/CloudAuth' import { cloudAuth } from '@/seqta/utils/CloudAuth'
import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte' import SignInToFavoriteModal from '../components/SignInToFavoriteModal.svelte'
import { consumePendingHighlightThemeId } from '@/seqta/utils/openThemeStoreWithHighlight'
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
@@ -123,39 +122,13 @@
} }
}; };
function focusThemeById(themeId: string) {
const match = themes.find((t) => t.id === themeId)
?? themes.find((t) => t.flavours?.some((f) => f.id === themeId));
if (match) {
activeTab = 'themes';
searchTerm = '';
displayTheme = match;
}
}
function onHighlightThemeEvent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.themeId && typeof detail.themeId === 'string') {
focusThemeById(detail.themeId);
}
}
// On mount // On mount
onMount(async () => { onMount(async () => {
window.addEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
await fetchThemes(); await fetchThemes();
await fetchCurrentThemes(); await fetchCurrentThemes();
darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true'; darkMode = (await browser.storage.local.get('DarkMode')).DarkMode === 'true';
darkMode = $settingsState.DarkMode; darkMode = $settingsState.DarkMode;
const pending = consumePendingHighlightThemeId();
if (pending) focusThemeById(pending);
return () => {
window.removeEventListener('bsplus:highlight-theme', onHighlightThemeEvent);
};
}); });
// Filter themes (list is already featured-first, then newest; filter preserves order) // Filter themes (list is already featured-first, then newest; filter preserves order)
+2 -1
View File
@@ -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",
@@ -7,11 +7,11 @@ import {
import { type Plugin } from "@/plugins/core/types"; import { type Plugin } from "@/plugins/core/types";
import stringToHTML from "@/seqta/utils/stringToHTML"; import stringToHTML from "@/seqta/utils/stringToHTML";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import ReactFiber from "@/seqta/utils/ReactFiber.ts";
import { import {
clearStuck, clearStuck,
getClassByPattern, getClassByPattern,
initStorage, initStorage,
injectWeightingsTab,
letterToNumber, letterToNumber,
parseAssessments, parseAssessments,
processAssessments, processAssessments,
@@ -20,7 +20,6 @@ import {
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, string>; weightings: Record<string, string>;
assessments: Record<string, string>; assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
} }
const settings = defineSettings({ const settings = defineSettings({
@@ -38,8 +37,6 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
name: "Assessment Averages", name: "Assessment Averages",
@@ -61,56 +58,17 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
await parseAssessments(api); await parseAssessments(api);
await renderSubjectAverage(api);
overrideListenerController?.abort();
overrideListenerController = new AbortController();
document.addEventListener(
"betterseqta:overrideChanged",
() => renderSubjectAverage(api),
{ signal: overrideListenerController.signal },
);
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
const observer = new MutationObserver(() => {
applySubjectColourToOverallResult();
});
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
}
});
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
injectWeightingsTab(api);
});
},
};
let renderInFlight = false;
async function renderSubjectAverage(api: any) {
if (renderInFlight) return;
renderInFlight = true;
try {
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
// Remove existing subject average before re-rendering
Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`),
)
.find((el) => el.textContent === "Subject Average")
?.closest("[class*='AssessmentItem__AssessmentItem___']")
?.remove();
const sampleAssessmentItem = document.querySelector( const sampleAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___']", "[class*='AssessmentItem__AssessmentItem___']",
); );
if (!sampleAssessmentItem) return; if (!sampleAssessmentItem) return;
const assessmentItemClass = const assessmentItemClass =
Array.from(sampleAssessmentItem.classList).find((c) => Array.from(sampleAssessmentItem.classList).find((c) =>
c.startsWith("AssessmentItem__AssessmentItem___"), c.startsWith("AssessmentItem__AssessmentItem___"),
) || ""; ) || "";
const metaContainerClass = getClassByPattern( const metaContainerClass = getClassByPattern(
sampleAssessmentItem, sampleAssessmentItem,
"AssessmentItem__metaContainer___", "AssessmentItem__metaContainer___",
@@ -128,25 +86,11 @@ async function renderSubjectAverage(api: any) {
"AssessmentItem__title___", "AssessmentItem__title___",
); );
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const thermoscoreElement = document.querySelector( const thermoscoreElement = document.querySelector(
"[class*='Thermoscore__Thermoscore___']", "[class*='Thermoscore__Thermoscore___']",
); );
if (!thermoscoreElement) return; if (!thermoscoreElement) return;
const thermoscoreClass = const thermoscoreClass =
Array.from(thermoscoreElement.classList).find((c) => Array.from(thermoscoreElement.classList).find((c) =>
c.startsWith("Thermoscore__Thermoscore___"), c.startsWith("Thermoscore__Thermoscore___"),
@@ -160,6 +104,33 @@ async function renderSubjectAverage(api: any) {
"Thermoscore__text___", "Thermoscore__text___",
); );
const assessmentsList = document.querySelector(
"#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']",
);
if (!assessmentsList) return;
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(
`[class*='AssessmentItem__AssessmentItem___']`,
),
).filter(
(item) =>
!item
.querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.includes("Subject Average"),
);
const { weightedTotal, totalWeight, hasInaccurateWeighting, count } =
await processAssessments(api, assessmentItems);
if (!count || totalWeight === 0) return;
const avg = weightedTotal / totalWeight; const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
@@ -169,8 +140,17 @@ async function renderSubjectAverage(api: any) {
}, },
{} as Record<number, string>, {} as Record<number, string>,
); );
const letterAvg = numberToLetter[rounded] ?? "N/A"; const letterAvg = numberToLetter[rounded] ?? "N/A";
const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`; const display = api.settings.lettergrade
? letterAvg
: `${avg.toFixed(2)}%`;
const existing = assessmentsList.querySelector(
`[class*='AssessmentItem__title___']`,
);
if (existing?.textContent === "Subject Average") return;
let warningHTML = ""; let warningHTML = "";
if (hasInaccurateWeighting) { if (hasInaccurateWeighting) {
warningHTML = /* html */ ` warningHTML = /* html */ `
@@ -179,6 +159,7 @@ async function renderSubjectAverage(api: any) {
</div> </div>
`; `;
} }
assessmentsList.insertBefore( assessmentsList.insertBefore(
stringToHTML(/* html */ ` stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
@@ -199,11 +180,21 @@ async function renderSubjectAverage(api: any) {
`).firstChild!, `).firstChild!,
assessmentsList.firstChild, assessmentsList.firstChild,
); );
applySubjectColourToOverallResult(); applySubjectColourToOverallResult();
} finally {
renderInFlight = false; const observer = new MutationObserver(() => {
} applySubjectColourToOverallResult();
});
const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) {
observer.observe(wrapper, { childList: true, subtree: true });
setTimeout(() => observer.disconnect(), 10000);
} }
});
},
};
function applySubjectColourToOverallResult() { function applySubjectColourToOverallResult() {
const selectedAssessmentItem = document.querySelector( const selectedAssessmentItem = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
+121 -370
View File
@@ -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;
@@ -17,9 +65,6 @@ export async function initStorage(api: any) {
if (!api.storage.assessments) { if (!api.storage.assessments) {
api.storage.assessments = {}; api.storage.assessments = {};
} }
if (!api.storage.weightingOverrides) {
api.storage.weightingOverrides = {};
}
} }
export function clearStuck(api: any) { export function clearStuck(api: any) {
@@ -66,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));
@@ -78,104 +123,78 @@ 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,
) { ) {
let statsContainer = assessmentItem.querySelector( const statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, `[class*='AssessmentItem__stats___']`,
) as HTMLElement | null; ) as HTMLElement;
if (!statsContainer) { if (
const statsClass = getClassByPattern(document, "AssessmentItem__stats___"); !statsContainer ||
statsContainer = document.createElement("div"); statsContainer.querySelector(".betterseqta-weight-label")
statsContainer.className = statsClass; )
statsContainer.classList.add("betterseqta-stats-container");
const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`);
if (thermoscore) {
thermoscore.insertAdjacentElement("afterend", statsContainer);
} else {
assessmentItem.appendChild(statsContainer);
}
}
const hasNativeLabel = !!statsContainer.querySelector(
`[class*='Label__Label___']:not(.betterseqta-weight-label)`,
);
statsContainer.style.justifyContent = hasNativeLabel
? "space-between"
: "flex-end";
const displayText =
weighting && weighting !== "processing" && weighting !== "N/A"
? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label",
) as HTMLElement | null;
if (existingLabel) {
const textNodes = Array.from(existingLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
if (textNodes.length) textNodes[0].textContent = displayText;
return; return;
}
statsContainer.style.display = "flex"; const label = statsContainer.querySelector(
statsContainer.style.alignItems = "center";
statsContainer.style.width = "100%";
// Try to clone an existing label from the stats container first,
// fall back to building from scratch if none exists
const existingNativeLabel = statsContainer.querySelector(
`[class*='Label__Label___']`, `[class*='Label__Label___']`,
) as HTMLElement | null; ) as HTMLElement;
const weightLabel = existingNativeLabel if (!label) return;
? (existingNativeLabel.cloneNode(true) as HTMLElement)
: (() => {
const labelClass = getClassByPattern(document, "Label__Label___");
const innerTextClass = getClassByPattern(document, "Label__innerText___");
const el = document.createElement("label");
el.className = labelClass;
el.innerHTML = `<div class="${innerTextClass}">Weight</div>`;
return el;
})();
const weightLabel = label.cloneNode(true) as HTMLElement;
weightLabel.classList.add("betterseqta-weight-label"); weightLabel.classList.add("betterseqta-weight-label");
weightLabel.style.flex = "none";
weightLabel.style.width = "fit-content";
const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`); const innerTextDiv = weightLabel.querySelector(
`[class*='Label__innerText___']`,
);
if (innerTextDiv) innerTextDiv.textContent = "Weight"; if (innerTextDiv) innerTextDiv.textContent = "Weight";
const textNodes = Array.from(weightLabel.childNodes).filter( const textNodes = Array.from(weightLabel.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE, (node) => node.nodeType === Node.TEXT_NODE,
); );
if (textNodes.length) { if (textNodes.length) {
textNodes[0].textContent = displayText; textNodes[0].textContent =
} else { weighting && weighting !== "processing"
weightLabel.appendChild(document.createTextNode(displayText)); ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%`
: "N/A";
} }
// Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414).
statsContainer.style.display = "flex";
statsContainer.style.flexDirection = "column";
statsContainer.style.alignItems = "flex-end";
statsContainer.style.gap = "2px";
statsContainer.style.justifyContent = "center";
weightLabel.style.position = "relative";
weightLabel.style.inset = "unset";
weightLabel.style.transform = "none";
statsContainer.appendChild(weightLabel); 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() {
@@ -262,11 +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 } = const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls();
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);
@@ -274,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() {
@@ -524,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"))
@@ -536,11 +549,22 @@ async function handleWeightings(mark: any, api: any) {
} }
} }
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); 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 = {
@@ -555,11 +579,7 @@ export async function parseAssessments(api: any) {
"[class*='AssessmentList__items___']", "[class*='AssessmentList__items___']",
).getState(); ).getState();
const marks = [ const marks = state["marks"];
...(state["marks"] ?? []),
...(state["upcoming"] ?? []),
...(state["pending"] ?? []),
];
if (!marks) return; if (!marks) return;
await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); await Promise.all(marks.map((mark: any) => handleWeightings(mark, api)));
@@ -572,6 +592,15 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let count = 0; let count = 0;
for (const assessmentItem of assessmentItems) { for (const assessmentItem of assessmentItems) {
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const titleEl = assessmentItem.querySelector( const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
); );
@@ -581,23 +610,12 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
if (!title) continue; if (!title) continue;
const assessmentID = api.storage.assessments?.[title]; const assessmentID = api.storage.assessments?.[title];
const autoWeighting = assessmentID const weighting = assessmentID
? api.storage.weightings?.[assessmentID] ? api.storage.weightings?.[assessmentID]
: undefined; : undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const weighting = override ?? autoWeighting;
createWeightLabel(assessmentItem, weighting); createWeightLabel(assessmentItem, weighting);
const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
if ( if (
weighting === null || weighting === null ||
weighting === undefined || weighting === undefined ||
@@ -605,7 +623,8 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
weighting === "processing" weighting === "processing"
) { ) {
hasInaccurateWeighting = true; hasInaccurateWeighting = true;
continue weightedTotal += grade;
totalWeight += 1;
} else { } else {
const weight = parseFloat(weighting); const weight = parseFloat(weighting);
@@ -628,271 +647,3 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
count, count,
}; };
} }
function resolveTabSetClasses(): Record<string, string> {
const patterns = [
"TabSet__tabsheet___",
"TabSet__hidden___",
"TabSet__selected___",
"TabSet__disappearToLeft___",
"TabSet__disappearToRight___",
"TabSet__appearFromRight___",
"TabSet__appearFromLeft___",
];
const resolved: Record<string, string> = {};
// First pass: scan live DOM elements (fast, covers currently-applied classes)
const allClasses = Array.from(
document.querySelectorAll('[class*="TabSet__"]'),
).flatMap((el) => Array.from(el.classList));
for (const pattern of patterns) {
const found = allClasses.find((c) => c.startsWith(pattern));
if (found) resolved[pattern] = found;
}
// Second pass: scan stylesheets for any classes not yet in the DOM
// (e.g. animation classes that haven't been applied yet)
const missing = patterns.filter((p) => !resolved[p]);
if (missing.length > 0) {
try {
for (const sheet of Array.from(document.styleSheets)) {
if (missing.every((p) => resolved[p])) break;
try {
for (const rule of Array.from(sheet.cssRules ?? [])) {
if (!(rule instanceof CSSStyleRule)) continue;
const selectorClasses =
rule.selectorText.match(/\.([\w-]+)/g) ?? [];
for (const pattern of missing) {
if (!resolved[pattern]) {
const match = selectorClasses.find((c) =>
c.slice(1).startsWith(pattern),
);
if (match) resolved[pattern] = match.slice(1);
}
}
}
} catch {
// Cross-origin stylesheet
}
}
} catch {}
}
// Fallback: use the base pattern as-is so the function doesn't crash,
// though styles won't apply if the hash is truly unknown.
for (const pattern of patterns) {
if (!resolved[pattern]) resolved[pattern] = pattern;
}
return resolved;
}
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
);
const title = titleEl?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const rawWeight = assessmentID
? api.storage.weightings?.[assessmentID]
: undefined;
const weightingUnavailable = rawWeight === "N/A";
const autoWeight =
rawWeight && rawWeight !== "processing" && rawWeight !== "N/A"
? rawWeight
: undefined;
const override = assessmentID
? api.storage.weightingOverrides?.[assessmentID]
: undefined;
const statusNote = !assessmentID
? ""
: rawWeight === "processing"
? "Weighting is still being detected."
: weightingUnavailable
? "No weighting was found in the marksheet. Set one manually."
: "Overrides the auto-detected value.";
sheet.innerHTML = `
<style>
#betterseqta-weight-override::placeholder {
opacity: 0.4;
}
</style>
<div style="padding:16px;max-width:360px">
<h2 style="margin:0 0 4px;font-size:15px;font-weight:600">Weighting Override</h2>
<p style="margin:0 0 16px;font-size:12px;opacity:0.6">
Set the weighting for this assessment.
${statusNote}
</p>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<label style="font-size:13px;opacity:0.7;flex-shrink:0">Auto-detected</label>
<span style="font-size:13px;opacity:${autoWeight != null ? "1" : "0.4"}">${autoWeight != null ? `${autoWeight}%` : "none"}</span>
</div>
<div style="display:flex;align-items:center;gap:12px">
<label for="betterseqta-weight-override" style="font-size:13px;opacity:0.7;flex-shrink:0">Override %</label>
<input
id="betterseqta-weight-override"
type="number"
min="0"
step="5"
placeholder="${autoWeight ?? ""}"
value="${override ?? ""}"
${!assessmentID ? "disabled" : ""}
style="
width:90px;
padding:5px 8px;
border-radius:6px;
border:1px solid rgba(128,128,128,0.3);
background:rgba(128,128,128,0.08);
color:inherit;
font-size:13px;
outline:none;
"
/>
</div>
<div style="margin-top:10px;min-height:18px">
<span class="betterseqta-save-status" style="font-size:12px;opacity:0.5"></span>
</div>
${!assessmentID ? `<p style="font-size:12px;color:rgba(255,80,80,0.8);margin-top:8px">Assessment not yet indexed — try refreshing.</p>` : ""}
</div>
`;
if (!assessmentID) return;
const input = sheet.querySelector(
"#betterseqta-weight-override",
) as HTMLInputElement;
const statusEl = sheet.querySelector(
".betterseqta-save-status",
) as HTMLElement;
const save = () => {
const raw = input.value.trim();
if (raw === "") {
const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides;
api.storage.weightingOverrides = rest;
} else {
const val = parseFloat(raw);
if (isNaN(val) || val < 0) {
input.style.borderColor = "rgba(255,80,80,0.6)";
statusEl.textContent = "Invalid. Must be 0 or greater";
statusEl.style.color = "rgba(255,80,80,0.8)";
return;
}
input.style.borderColor = "rgba(128,128,128,0.3)";
api.storage.weightingOverrides = {
...api.storage.weightingOverrides,
[assessmentID]: String(val),
};
}
statusEl.textContent = "Saved";
statusEl.style.color = "";
setTimeout(() => (statusEl.textContent = ""), 2000);
document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged"));
};
input.addEventListener("blur", save);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
input.blur();
save();
}
});
input.addEventListener("input", () => {
input.style.borderColor = "rgba(128,128,128,0.3)";
if (statusEl.textContent === "Invalid. Must be 0 or greater.")
statusEl.textContent = "";
});
}
export function injectWeightingsTab(api: any) {
const tabList = document.querySelector(
'[class*="TabSet__tabs___"]',
) as HTMLElement;
const container = document.querySelector(
'[class*="TabSet__tabContainer___"]',
) as HTMLElement;
if (!tabList || !container) return;
if (tabList.querySelector(".betterseqta-weightings-tab")) return;
const cls = resolveTabSetClasses();
const prefix = (tabList.querySelector("li") as HTMLElement).id.replace(
/-tab-\d+$/,
"",
);
const newIndex = tabList.querySelectorAll("li").length;
const newTab = document.createElement("li");
newTab.id = `${prefix}-tab-${newIndex}`;
newTab.className = "";
newTab.setAttribute("aria-selected", "false");
newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`);
newTab.classList.add("betterseqta-weightings-tab");
newTab.textContent = "Weightings";
tabList.appendChild(newTab);
const newSheet = document.createElement("div");
newSheet.id = `${prefix}-tabsheet-${newIndex}`;
newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`);
newSheet.className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
cls["TabSet__disappearToRight___"],
].join(" ");
container.appendChild(newSheet);
let populated = false;
newTab.addEventListener("click", () => {
if (!populated) {
buildWeightingsTabContent(api, newSheet);
populated = true;
}
});
const allTabs = Array.from(tabList.querySelectorAll("li"));
const allSheets = Array.from(
container.querySelectorAll('[class*="tabsheet"]'),
);
allTabs.forEach((tab, i) => {
tab.addEventListener("click", () => {
const currentIndex = allTabs.findIndex((t) =>
t.className.includes("TabSet__selected___"),
);
if (i === currentIndex) return;
const goingRight = i > currentIndex;
allTabs.forEach((t) => {
t.className = "";
t.setAttribute("aria-selected", "false");
});
allSheets[currentIndex].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__hidden___"],
goingRight
? cls["TabSet__disappearToLeft___"]
: cls["TabSet__disappearToRight___"],
].join(" ");
allSheets[i].className = [
cls["TabSet__tabsheet___"],
cls["TabSet__selected___"],
goingRight
? cls["TabSet__appearFromRight___"]
: cls["TabSet__appearFromLeft___"],
].join(" ");
tab.className = cls["TabSet__selected___"];
tab.setAttribute("aria-selected", "true");
});
});
}
@@ -28,9 +28,17 @@ async function fetchJSON(url: string, body: any) {
async function loadSubjects() { async function loadSubjects() {
const res = await fetchJSON("/seqta/student/load/subjects?", {}); const res = await fetchJSON("/seqta/student/load/subjects?", {});
return res.payload const activeGroup = res.payload.find((s: any) => s.active === 1);
.filter((s: any) => s.active === 1) const activeYear = activeGroup?.year;
const allSubjects = res.payload
.filter((s: any) => s.year === activeYear)
.flatMap((s: any) => s.subjects); .flatMap((s: any) => s.subjects);
const seen = new Set<string>();
return allSubjects.filter((s: Subject) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
} }
async function loadPrefs(student: number) { async function loadPrefs(student: number) {
@@ -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;
+124 -338
View File
@@ -22,7 +22,6 @@ interface Folder {
id: string; id: string;
name: string; name: string;
color: string; color: string;
emoji: string;
} }
interface MessageFoldersStorage { interface MessageFoldersStorage {
@@ -35,33 +34,12 @@ const FOLDER_COLORS = [
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
]; ];
const FOLDER_HEROICONS = [
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`,
`<svg style="width:16px;height:16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`,
];
const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`; const FOLDER_ICON_SVG = `<svg style="width:24px;height:24px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>`;
const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`; const PLUS_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`;
const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`; const CHECK_SVG_WHITE = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#fff" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`;
const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`; const CLOSE_SVG = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>`;
const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`; const EDIT_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>`;
const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`; const TRASH_SVG = `<svg style="width:12px;height:12px;flex-shrink:0" viewBox="0 0 24 24"><path fill="#888" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>`;
const CHEVRON_SVG = `<svg style="width:12px;height:12px;flex-shrink:0;transition:transform .2s" viewBox="0 0 24 24"><path fill="#888" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>`;
const DRAG_SVG = `<svg style="width:14px;height:14px;flex-shrink:0;cursor:grab" viewBox="0 0 24 24"><path fill="#888" d="M6.5 12.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3z"/></svg>`;
function generateId(): string { function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
@@ -71,7 +49,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
id: "messageFolders", id: "messageFolders",
name: "Message Folders", name: "Message Folders",
description: "Organize direct messages into custom folders", description: "Organize direct messages into custom folders",
version: "2.0.0", version: "1.0.0",
settings: messageFoldersSettings, settings: messageFoldersSettings,
disableToggle: true, disableToggle: true,
defaultEnabled: true, defaultEnabled: true,
@@ -92,9 +70,10 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
let actionsObserver: MutationObserver | null = null; let actionsObserver: MutationObserver | null = null;
let openDropdown: HTMLElement | null = null; let openDropdown: HTMLElement | null = null;
let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null; let dropdownCloseHandler: ((e: MouseEvent) => void) | null = null;
let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = []; const unregisters: Array<{ unregister: () => void }> = [];
// ── Storage accessors ──
const getFolders = (): Folder[] => api.storage.folders ?? []; const getFolders = (): Folder[] => api.storage.folders ?? [];
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {}; const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
@@ -115,18 +94,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
return ids; return ids;
}; };
const assignMessageToFolder = (messageId: string, folderId: string, add: boolean) => {
const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = [];
const idx = assignments[folderId].indexOf(messageId);
if (add && idx < 0) {
assignments[folderId].push(messageId);
} else if (!add && idx >= 0) {
assignments[folderId].splice(idx, 1);
}
saveAssignments(assignments);
};
const toggleMessageInFolder = (messageId: string, folderId: string) => { const toggleMessageInFolder = (messageId: string, folderId: string) => {
const assignments = getAssignments(); const assignments = getAssignments();
if (!assignments[folderId]) assignments[folderId] = []; if (!assignments[folderId]) assignments[folderId] = [];
@@ -162,28 +129,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
return api.settings.showTagsInAllMessages || activeFolderId !== null; return api.settings.showTagsInAllMessages || activeFolderId !== null;
}; };
const getSelectedMessageId = (): string | null => { // ── Confirm modal ──
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
return selectedMsg?.getAttribute("data-message") ?? null;
};
const getMessageIdFromEvent = (target: HTMLElement): string | null => { const showConfirmModal = (
const li = target.closest("li[data-message]"); title: string,
return li?.getAttribute("data-message") ?? null; message: string,
}; onConfirm: () => void,
) => {
const getAllVisibleMessageIds = (): string[] => {
const ids: string[] = [];
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
const id = li.getAttribute("data-message");
if (id) ids.push(id);
});
return ids;
};
const showConfirmModal = (title: string, message: string, onConfirm: () => void) => {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "bsplus-modal-overlay"; overlay.className = "bsplus-modal-overlay";
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.className = "bsplus-modal"; modal.className = "bsplus-modal";
modal.innerHTML = ` modal.innerHTML = `
@@ -195,13 +150,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
</div> </div>
`; `;
overlay.appendChild(modal); overlay.appendChild(modal);
const remove = () => { const remove = () => {
overlay.remove(); overlay.remove();
document.removeEventListener("keydown", onKey); document.removeEventListener("keydown", onKey);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") remove(); if (e.key === "Escape") remove();
}; };
overlay.addEventListener("click", (e) => { overlay.addEventListener("click", (e) => {
if (e.target === overlay) remove(); if (e.target === overlay) remove();
}); });
@@ -210,42 +168,36 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
onConfirm(); onConfirm();
remove(); remove();
}); });
document.body.appendChild(overlay); document.body.appendChild(overlay);
document.addEventListener("keydown", onKey); document.addEventListener("keydown", onKey);
}; };
// ── Sidebar folder UI ──
const renderSidebarFolders = () => { const renderSidebarFolders = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return; if (!sidebar) return;
const ol = sidebar.querySelector("ol"); const ol = sidebar.querySelector("ol");
if (!ol) return; if (!ol) return;
let section = ol.querySelector(".bsplus-folders-section") as HTMLElement; let section = ol.querySelector(".bsplus-folders-section");
if (!section) { if (!section) {
section = document.createElement("div"); section = document.createElement("div");
section.className = "bsplus-folders-section"; section.className = "bsplus-folders-section";
ol.appendChild(section); ol.appendChild(section);
} }
foldedSection = section;
const folders = getFolders(); const folders = getFolders();
const existingInput = section.querySelector(".bsplus-folder-input");
const existingColors = section.querySelector(".bsplus-folder-colors");
section.innerHTML = ""; section.innerHTML = "";
// Header
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "bsplus-folders-header"; header.className = "bsplus-folders-header";
header.dataset.folded = "false";
const collapseBtn = document.createElement("button");
collapseBtn.className = "bsplus-folders-collapse";
collapseBtn.innerHTML = CHEVRON_SVG;
collapseBtn.title = "Collapse";
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isFolded = collapseBtn.classList.toggle("bsplus-folded");
section.classList.toggle("bsplus-section-folded", isFolded);
collapseBtn.title = isFolded ? "Expand" : "Collapse";
});
header.appendChild(collapseBtn);
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = "Folders"; label.textContent = "Folders";
@@ -262,8 +214,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
header.appendChild(addBtn); header.appendChild(addBtn);
section.appendChild(header); section.appendChild(header);
// "All Messages" item
const allItem = document.createElement("div"); const allItem = document.createElement("div");
allItem.className = `bsplus-folder-item bsplus-all-msgs${activeFolderId === null ? " bsplus-folder-active" : ""}`; allItem.className = `bsplus-folder-item${activeFolderId === null ? " bsplus-folder-active" : ""}`;
allItem.innerHTML = ` allItem.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" style="fill: currentcolor; opacity: 0.5; flex-shrink: 0;"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
<span class="bsplus-folder-name">All Messages</span> <span class="bsplus-folder-name">All Messages</span>
@@ -273,34 +226,20 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
applyFolderFilter(); applyFolderFilter();
applyBadges(); applyBadges();
renderSidebarFolders(); renderSidebarFolders();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
}); });
section.appendChild(allItem); section.appendChild(allItem);
// Folder items
for (const folder of folders) { for (const folder of folders) {
const item = document.createElement("div"); const item = document.createElement("div");
item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`; item.className = `bsplus-folder-item${activeFolderId === folder.id ? " bsplus-folder-active" : ""}`;
item.dataset.folderId = folder.id; item.dataset.folderId = folder.id;
item.draggable = true;
const dragHandle = document.createElement("div");
dragHandle.className = "bsplus-folder-drag";
dragHandle.innerHTML = DRAG_SVG;
item.appendChild(dragHandle);
const dot = document.createElement("div"); const dot = document.createElement("div");
dot.className = "bsplus-folder-dot"; dot.className = "bsplus-folder-dot";
dot.style.background = folder.color; dot.style.background = folder.color;
item.appendChild(dot); item.appendChild(dot);
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
item.appendChild(iconSpan);
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "bsplus-folder-name"; name.className = "bsplus-folder-name";
name.textContent = folder.name; name.textContent = folder.name;
@@ -325,7 +264,10 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
deleteBtn.innerHTML = TRASH_SVG; deleteBtn.innerHTML = TRASH_SVG;
deleteBtn.addEventListener("click", (e) => { deleteBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
showConfirmModal("Delete folder", `Remove "${folder.name}"? Messages won't be deleted.`, () => { showConfirmModal(
"Delete folder",
`Remove "${folder.name}"? Messages won't be deleted.`,
() => {
const folders = getFolders().filter((f) => f.id !== folder.id); const folders = getFolders().filter((f) => f.id !== folder.id);
saveFolders(folders); saveFolders(folders);
const assignments = getAssignments(); const assignments = getAssignments();
@@ -335,7 +277,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
applyFolderFilter(); applyFolderFilter();
applyBadges(); applyBadges();
renderSidebarFolders(); renderSidebarFolders();
}); },
);
}); });
actions.appendChild(deleteBtn); actions.appendChild(deleteBtn);
@@ -352,89 +295,15 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
applyFolderFilter(); applyFolderFilter();
applyBadges(); applyBadges();
renderSidebarFolders(); renderSidebarFolders();
setTimeout(() => {
applyFolderFilter();
applyBadges();
}, 100);
});
item.addEventListener("dragstart", (e) => {
e.dataTransfer?.setData("text/plain", `reorder:${folder.id}`);
item.classList.add("bsplus-dragging");
});
item.addEventListener("dragend", () => {
item.classList.remove("bsplus-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
item.addEventListener("dragover", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:") && !data.includes(folder.id)) {
item.classList.add("bsplus-drag-over");
}
});
item.addEventListener("dragleave", () => {
item.classList.remove("bsplus-drag-over");
});
item.addEventListener("drop", (e) => {
e.preventDefault();
item.classList.remove("bsplus-drag-over");
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("reorder:")) {
const draggedId = data.replace("reorder:", "");
const folders = getFolders();
const draggedIdx = folders.findIndex((f) => f.id === draggedId);
const targetIdx = folders.findIndex((f) => f.id === folder.id);
if (draggedIdx >= 0 && targetIdx >= 0 && draggedIdx !== targetIdx) {
const [removed] = folders.splice(draggedIdx, 1);
folders.splice(targetIdx, 0, removed);
saveFolders(folders);
renderSidebarFolders();
}
}
}); });
section.appendChild(item); section.appendChild(item);
} }
section.addEventListener("dragover", (e) => { // Restore input if it was open
e.preventDefault(); if (existingInput || existingColors) {
}); // Don't restore let user re-trigger
section.addEventListener("drop", (e) => {
e.preventDefault();
const data = e.dataTransfer?.getData("text/plain") || "";
if (data.startsWith("msg:")) {
const messageId = data.replace("msg:", "");
const folderId = (e.target as HTMLElement).closest("[data-folder-id]")?.getAttribute("data-folder-id");
if (messageId && folderId) {
assignMessageToFolder(messageId, folderId, true);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
} }
}
});
attachDragListeners();
};
const attachDragListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-drag") === "true") return;
li.setAttribute("data-bsplus-drag", "true");
li.draggable = true;
li.addEventListener("dragstart", (e) => {
const id = li.getAttribute("data-message");
if (id) {
e.dataTransfer?.setData("text/plain", `msg:${id}`);
li.classList.add("bsplus-msg-dragging");
}
});
li.addEventListener("dragend", () => {
li.classList.remove("bsplus-msg-dragging");
document.querySelectorAll(".bsplus-folder-item").forEach((el) => el.classList.remove("bsplus-drag-over"));
});
});
}; };
const showNewFolderInput = (container: Element, editFolder?: Folder) => { const showNewFolderInput = (container: Element, editFolder?: Folder) => {
@@ -443,34 +312,16 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
container.querySelector(".bsplus-folder-colors")?.remove(); container.querySelector(".bsplus-folder-colors")?.remove();
let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)]; let selectedColor = editFolder?.color ?? FOLDER_COLORS[Math.floor(Math.random() * FOLDER_COLORS.length)];
let selectedIcon = editFolder?.emoji ?? FOLDER_HEROICONS[Math.floor(Math.random() * FOLDER_HEROICONS.length)];
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "bsplus-folder-input"; row.className = "bsplus-folder-input";
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "text"; input.type = "text";
input.placeholder = editFolder ? "Rename folder\u2026" : "Folder name\u2026"; input.placeholder = editFolder ? "Rename folder" : "Folder name";
input.value = editFolder?.name ?? ""; input.value = editFolder?.name ?? "";
input.maxLength = 30; input.maxLength = 30;
const iconBtn = document.createElement("button");
iconBtn.className = "bsplus-folder-icon-btn";
iconBtn.title = "Pick icon";
iconBtn.innerHTML = selectedIcon;
iconBtn.addEventListener("click", (e) => {
e.stopPropagation();
const picker = container.querySelector(".bsplus-icon-picker") as HTMLElement | null;
if (picker) {
picker.remove();
return;
}
showIconPicker(container, (iconSvg) => {
selectedIcon = iconSvg;
iconBtn.innerHTML = iconSvg;
});
});
const confirmBtn = document.createElement("button"); const confirmBtn = document.createElement("button");
confirmBtn.className = "bsplus-folder-input-confirm"; confirmBtn.className = "bsplus-folder-input-confirm";
confirmBtn.innerHTML = CHECK_SVG_WHITE; confirmBtn.innerHTML = CHECK_SVG_WHITE;
@@ -479,11 +330,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
cancelBtn.className = "bsplus-folder-input-cancel"; cancelBtn.className = "bsplus-folder-input-cancel";
cancelBtn.innerHTML = CLOSE_SVG; cancelBtn.innerHTML = CLOSE_SVG;
row.appendChild(iconBtn);
row.appendChild(input); row.appendChild(input);
row.appendChild(confirmBtn); row.appendChild(confirmBtn);
row.appendChild(cancelBtn); row.appendChild(cancelBtn);
// Color picker
const colorRow = document.createElement("div"); const colorRow = document.createElement("div");
colorRow.className = "bsplus-folder-colors"; colorRow.className = "bsplus-folder-colors";
for (const color of FOLDER_COLORS) { for (const color of FOLDER_COLORS) {
@@ -503,13 +354,14 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const confirm = () => { const confirm = () => {
const name = input.value.trim(); const name = input.value.trim();
if (!name) return; if (!name) return;
if (editFolder) { if (editFolder) {
const folders = getFolders().map((f) => const folders = getFolders().map((f) =>
f.id === editFolder.id ? { ...f, name, color: selectedColor, emoji: selectedIcon } : f, f.id === editFolder.id ? { ...f, name, color: selectedColor } : f,
); );
saveFolders(folders); saveFolders(folders);
} else { } else {
const folder: Folder = { id: generateId(), name, color: selectedColor, emoji: selectedIcon }; const folder: Folder = { id: generateId(), name, color: selectedColor };
saveFolders([...getFolders(), folder]); saveFolders([...getFolders(), folder]);
} }
applyBadges(); applyBadges();
@@ -534,38 +386,23 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
requestAnimationFrame(() => input.focus()); requestAnimationFrame(() => input.focus());
}; };
const showIconPicker = (container: Element, onSelect: (iconSvg: string) => void) => {
const existing = container.querySelector(".bsplus-icon-picker");
if (existing) existing.remove();
const picker = document.createElement("div");
picker.className = "bsplus-icon-picker";
for (const icon of FOLDER_HEROICONS) {
const btn = document.createElement("button");
btn.className = "bsplus-icon-opt";
btn.innerHTML = icon;
btn.addEventListener("click", (e) => {
e.stopPropagation();
onSelect(icon);
picker.remove();
});
picker.appendChild(btn);
}
container.appendChild(picker);
};
const showEditFolderInput = (container: Element, folder: Folder) => { const showEditFolderInput = (container: Element, folder: Folder) => {
showNewFolderInput(container, folder); showNewFolderInput(container, folder);
}; };
// ── Intercept native sidebar clicks to clear folder filter ──
const attachNativeSidebarListeners = () => { const attachNativeSidebarListeners = () => {
const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (!sidebar) return; if (!sidebar) return;
const ol = sidebar.querySelector("ol"); const ol = sidebar.querySelector("ol");
if (!ol) return; if (!ol) return;
ol.addEventListener("click", (e) => { ol.addEventListener("click", (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest(".bsplus-folders-section")) return; if (target.closest(".bsplus-folders-section")) return;
const li = target.closest("li"); const li = target.closest("li");
if (li && ol.contains(li)) { if (li && ol.contains(li)) {
if (activeFolderId !== null) { if (activeFolderId !== null) {
@@ -578,22 +415,47 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}); });
}; };
const closeDropdown = () => { // ── "Add to folder" button in message action bar ──
if (openDropdown) {
openDropdown.remove(); const injectFolderButton = (actionsBar: Element) => {
openDropdown = null; if (actionsBar.querySelector(".bsplus-folder-btn")) return;
}
if (dropdownCloseHandler) { const wrapper = document.createElement("div");
document.removeEventListener("click", dropdownCloseHandler, true); wrapper.className = "bsplus-folder-btn";
dropdownCloseHandler = null; wrapper.style.position = "relative";
wrapper.style.display = "inline-block";
const btn = document.createElement("button");
const btnClasses = actionsBar.querySelector("button")?.className ?? "";
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
closeDropdown();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
} }
}; };
const showFolderDropdown = (anchor: HTMLElement, messageId: string) => { const showFolderDropdown = (anchor: HTMLElement, messageId: string) => {
closeDropdown();
const dropdown = document.createElement("div"); const dropdown = document.createElement("div");
dropdown.className = "bsplus-folder-dropdown"; dropdown.className = "bsplus-folder-dropdown";
dropdown.dataset.msgId = messageId;
const folders = getFolders(); const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId); const currentFolderIds = getMessageFolderIds(messageId);
@@ -608,7 +470,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const isChecked = currentFolderIds.includes(folder.id); const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button"); const item = document.createElement("button");
item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`; item.className = `bsplus-folder-dropdown-item${isChecked ? " bsplus-checked" : ""}`;
item.dataset.folderId = folder.id;
const check = document.createElement("div"); const check = document.createElement("div");
check.className = "bsplus-folder-dropdown-check"; check.className = "bsplus-folder-dropdown-check";
@@ -620,26 +481,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
dot.className = "bsplus-folder-dot"; dot.className = "bsplus-folder-dot";
dot.style.background = folder.color; dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span"); const name = document.createElement("span");
name.textContent = folder.name; name.textContent = folder.name;
item.appendChild(check); item.appendChild(check);
item.appendChild(dot); item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name); item.appendChild(name);
item.addEventListener("click", (e) => { item.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
toggleMessageInFolder(messageId, folder.id); toggleMessageInFolder(messageId, folder.id);
const nowChecked = getMessageFolderIds(messageId).includes(folder.id); const nowChecked = getMessageFolderIds(messageId).includes(folder.id);
item.classList.toggle("bsplus-checked", nowChecked); item.classList.toggle("bsplus-checked", nowChecked);
check.style.borderColor = nowChecked ? folder.color : ""; check.style.borderColor = nowChecked ? folder.color : "";
check.style.background = nowChecked ? folder.color : ""; check.style.background = nowChecked ? folder.color : "";
applyBadges(); applyBadges();
applyFolderFilter(); applyFolderFilter();
renderSidebarFolders(); renderSidebarFolders();
@@ -662,105 +519,22 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
}, 0); }, 0);
}; };
const injectFolderButton = (actionsBar: Element) => { const closeDropdown = () => {
if (actionsBar.querySelector(".bsplus-folder-btn")) return; if (openDropdown) {
const wrapper = document.createElement("div"); openDropdown.remove();
wrapper.className = "bsplus-folder-btn"; openDropdown = null;
wrapper.style.position = "relative"; }
wrapper.style.display = "inline-block"; if (dropdownCloseHandler) {
const btn = document.createElement("button"); document.removeEventListener("click", dropdownCloseHandler, true);
const btnClasses = actionsBar.querySelector("button")?.className ?? ""; dropdownCloseHandler = null;
btn.className = btnClasses;
btn.title = "Add to folder";
btn.innerHTML = FOLDER_ICON_SVG;
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const selectedMsg = document.querySelector("[class*='MessageList__selected___']");
const messageId = selectedMsg?.getAttribute("data-message");
if (!messageId) return;
showFolderDropdown(wrapper, messageId);
});
wrapper.appendChild(btn);
const moreMenu = actionsBar.querySelector("[class*='MenuButton__Menu___']");
if (moreMenu) {
actionsBar.insertBefore(wrapper, moreMenu);
} else {
actionsBar.appendChild(wrapper);
} }
}; };
const showContextMenu = (e: MouseEvent, messageId: string) => { // ── Message badges ──
e.preventDefault();
e.stopPropagation();
closeDropdown();
const existing = document.querySelector(".bsplus-context-menu");
if (existing) existing.remove();
const menu = document.createElement("div");
menu.className = "bsplus-context-menu";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
const title = document.createElement("div");
title.className = "bsplus-context-title";
title.textContent = "Add to folder";
menu.appendChild(title);
const folders = getFolders();
const currentFolderIds = getMessageFolderIds(messageId);
if (folders.length === 0) {
const empty = document.createElement("div");
empty.className = "bsplus-context-empty";
empty.textContent = "No folders";
menu.appendChild(empty);
} else {
for (const folder of folders) {
const isChecked = currentFolderIds.includes(folder.id);
const item = document.createElement("button");
item.className = `bsplus-context-item${isChecked ? " bsplus-context-checked" : ""}`;
const dot = document.createElement("div");
dot.className = "bsplus-folder-dot";
dot.style.background = folder.color;
const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon";
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
const name = document.createElement("span");
name.textContent = folder.name;
item.appendChild(dot);
item.appendChild(iconSpan);
item.appendChild(name);
if (isChecked) {
const check = document.createElement("span");
check.className = "bsplus-context-checkmark";
check.textContent = "\u2713";
item.appendChild(check);
}
item.addEventListener("click", (e) => {
e.stopPropagation();
toggleMessageInFolder(messageId, folder.id);
applyBadges();
applyFolderFilter();
renderSidebarFolders();
menu.remove();
});
menu.appendChild(item);
}
}
document.body.appendChild(menu);
const closeMenu = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
menu.remove();
document.removeEventListener("click", closeMenu);
}
};
setTimeout(() => document.addEventListener("click", closeMenu), 0);
};
const applyBadges = () => { const applyBadges = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
if (!shouldShowBadgesInList()) { if (!shouldShowBadgesInList()) {
for (const li of messageItems) { for (const li of messageItems) {
const subject = li.querySelector("[class*='MessageList__subject___']"); const subject = li.querySelector("[class*='MessageList__subject___']");
@@ -772,20 +546,26 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
} }
return; return;
} }
const folders = getFolders(); const folders = getFolders();
const assignments = getAssignments(); const assignments = getAssignments();
for (const li of messageItems) { for (const li of messageItems) {
const msgId = li.getAttribute("data-message"); const msgId = li.getAttribute("data-message");
if (!msgId) continue; if (!msgId) continue;
let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null; let badgeContainer = li.querySelector(".bsplus-msg-badges") as HTMLElement | null;
const folderIds: string[] = [];
const folderIds = [];
for (const [fId, mIds] of Object.entries(assignments)) { for (const [fId, mIds] of Object.entries(assignments)) {
if (mIds.includes(msgId)) folderIds.push(fId); if (mIds.includes(msgId)) folderIds.push(fId);
} }
if (folderIds.length === 0) { if (folderIds.length === 0) {
badgeContainer?.remove(); badgeContainer?.remove();
continue; continue;
} }
if (!badgeContainer) { if (!badgeContainer) {
badgeContainer = document.createElement("div"); badgeContainer = document.createElement("div");
badgeContainer.className = "bsplus-msg-badges"; badgeContainer.className = "bsplus-msg-badges";
@@ -803,6 +583,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
li.appendChild(badgeContainer); li.appendChild(badgeContainer);
} }
} }
badgeContainer.innerHTML = ""; badgeContainer.innerHTML = "";
for (const fId of folderIds) { for (const fId of folderIds) {
const folder = folders.find((f) => f.id === fId); const folder = folders.find((f) => f.id === fId);
@@ -810,7 +591,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const badge = document.createElement("span"); const badge = document.createElement("span");
badge.className = "bsplus-msg-badge"; badge.className = "bsplus-msg-badge";
badge.style.background = folder.color; badge.style.background = folder.color;
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`; badge.textContent = folder.name;
badge.title = `Filter by "${folder.name}"`; badge.title = `Filter by "${folder.name}"`;
badge.addEventListener("click", (e) => { badge.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -824,9 +605,12 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
} }
}; };
// ── Folder filtering ──
const applyFolderFilter = () => { const applyFolderFilter = () => {
const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]"); const messageItems = document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]");
const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button"); const moreBtn = document.querySelector("[class*='MessageList__MessageList___'] ol > button");
if (activeFolderId === null) { if (activeFolderId === null) {
if (api.settings.hideFolderedMessagesInAll) { if (api.settings.hideFolderedMessagesInAll) {
for (const li of messageItems) { for (const li of messageItems) {
@@ -845,7 +629,9 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden"); if (moreBtn) (moreBtn as HTMLElement).classList.remove("bsplus-folder-hidden");
return; return;
} }
const folderMsgIds = getAssignments()[activeFolderId] ?? []; const folderMsgIds = getAssignments()[activeFolderId] ?? [];
for (const li of messageItems) { for (const li of messageItems) {
const msgId = li.getAttribute("data-message"); const msgId = li.getAttribute("data-message");
if (msgId && folderMsgIds.includes(msgId)) { if (msgId && folderMsgIds.includes(msgId)) {
@@ -857,35 +643,25 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden"); if (moreBtn) (moreBtn as HTMLElement).classList.add("bsplus-folder-hidden");
}; };
// ── Observers ──
const setupMessageListObserver = () => { const setupMessageListObserver = () => {
const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol"); const messageList = document.querySelector("[class*='MessageList__MessageList___'] ol");
if (!messageList || messageListObserver) return; if (!messageList || messageListObserver) return;
messageListObserver = new MutationObserver(() => { messageListObserver = new MutationObserver(() => {
applyBadges(); applyBadges();
applyFolderFilter(); applyFolderFilter();
attachDragListeners();
attachContextMenuListeners();
}); });
messageListObserver.observe(messageList, { childList: true, subtree: false }); messageListObserver.observe(messageList, { childList: true, subtree: false });
}; };
const attachContextMenuListeners = () => {
document.querySelectorAll("[class*='MessageList__MessageList___'] ol > li[data-message]").forEach((li) => {
if (li.getAttribute("data-bsplus-ctx") === "true") return;
li.setAttribute("data-bsplus-ctx", "true");
li.addEventListener("contextmenu", (e) => {
const msgId = li.getAttribute("data-message");
if (msgId) {
showContextMenu(e, msgId);
}
});
});
};
const setupActionsObserver = () => { const setupActionsObserver = () => {
if (actionsObserver) return; if (actionsObserver) return;
const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages"); const target = document.querySelector("[class*='Viewer__Viewer___']") ?? document.querySelector("div.messages");
if (!target) return; if (!target) return;
actionsObserver = new MutationObserver(() => { actionsObserver = new MutationObserver(() => {
const actionsBar = document.querySelector("[class*='Message__actions___']"); const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) { if (actionsBar && !actionsBar.querySelector(".bsplus-folder-btn")) {
@@ -895,19 +671,28 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
actionsObserver.observe(target, { childList: true, subtree: true }); actionsObserver.observe(target, { childList: true, subtree: true });
}; };
// ── Main page handler ──
const handleMessagesPage = async () => { const handleMessagesPage = async () => {
await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100); await waitForElm("[class*='Viewer__sidebar___'] ol", true, 50, 100);
renderSidebarFolders(); renderSidebarFolders();
attachNativeSidebarListeners(); attachNativeSidebarListeners();
await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100); await waitForElm("[class*='MessageList__MessageList___'] ol", true, 50, 100);
applyBadges(); applyBadges();
applyFolderFilter(); applyFolderFilter();
setupMessageListObserver(); setupMessageListObserver();
// The actions bar only exists when a message is selected/open,
// so we observe the whole viewer for it to appear dynamically
setupActionsObserver(); setupActionsObserver();
attachDragListeners();
attachContextMenuListeners(); // If a message is already selected, inject immediately
const actionsBar = document.querySelector("[class*='Message__actions___']"); const actionsBar = document.querySelector("[class*='Message__actions___']");
if (actionsBar) injectFolderButton(actionsBar); if (actionsBar) injectFolderButton(actionsBar);
// Re-observe the sidebar for SEQTA re-renders
const sidebar = document.querySelector("[class*='Viewer__sidebar___']"); const sidebar = document.querySelector("[class*='Viewer__sidebar___']");
if (sidebar && !sidebarObserver) { if (sidebar && !sidebarObserver) {
sidebarObserver = new MutationObserver(() => { sidebarObserver = new MutationObserver(() => {
@@ -921,8 +706,11 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
} }
}; };
// ── Lifecycle ──
const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage); const mountUnsub = api.seqta.onMount("div.messages", handleMessagesPage);
unregisters.push(mountUnsub); unregisters.push(mountUnsub);
unregisters.push( unregisters.push(
api.settings.onChange("showTagsInAllMessages", () => { api.settings.onChange("showTagsInAllMessages", () => {
applyBadges(); applyBadges();
@@ -944,7 +732,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove()); document.querySelectorAll(".bsplus-folders-section").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove()); document.querySelectorAll(".bsplus-folder-btn").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove()); document.querySelectorAll(".bsplus-msg-badges").forEach((el) => el.remove());
document.querySelectorAll(".bsplus-context-menu").forEach((el) => el.remove());
document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => { document.querySelectorAll("[class*='MessageList__subject___']").forEach((subject) => {
if (subject.querySelector(".bsplus-subject-text")) { if (subject.querySelector(".bsplus-subject-text")) {
restoreSubjectPlain(subject); restoreSubjectPlain(subject);
@@ -954,7 +741,6 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
el.classList.remove("bsplus-folder-hidden"), el.classList.remove("bsplus-folder-hidden"),
); );
document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove()); document.querySelectorAll(".bsplus-modal-overlay").forEach((el) => el.remove());
}; };
}, },
}; };
+9 -249
View File
@@ -3,21 +3,12 @@
border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2)); border-top: 1px solid var(--background-secondary, rgba(128, 128, 128, 0.2));
margin-top: 4px; margin-top: 4px;
padding-top: 4px; padding-top: 4px;
transition: opacity .2s;
}
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-item,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-input,
.bsplus-folders-section.bsplus-section-folded .bsplus-folder-colors,
.bsplus-folders-section.bsplus-section-folded .bsplus-emoji-picker,
.bsplus-folders-section.bsplus-section-folded .bsplus-all-msgs {
display: none !important;
} }
.bsplus-folders-header { .bsplus-folders-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; justify-content: space-between;
padding: 6px 12px 2px; padding: 6px 12px 2px;
user-select: none; user-select: none;
} }
@@ -29,33 +20,6 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
color: var(--text-primary, #666); color: var(--text-primary, #666);
opacity: 0.5; opacity: 0.5;
flex: 1;
}
.bsplus-folders-collapse {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 18px !important;
height: 18px !important;
min-width: 0 !important;
border: none !important;
background: transparent !important;
opacity: 0.4;
cursor: pointer;
border-radius: 4px !important;
padding: 0 !important;
margin: 0 !important;
transition: all .2s;
}
.bsplus-folders-collapse:hover {
opacity: 0.8;
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folders-collapse.bsplus-folded svg {
transform: rotate(-90deg);
} }
.bsplus-folders-add-btn { .bsplus-folders-add-btn {
@@ -87,21 +51,12 @@
align-items: center; align-items: center;
padding: 6px 12px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease, opacity 0.2s; transition: background 0.15s ease;
position: relative; position: relative;
gap: 6px; gap: 8px;
user-select: none; user-select: none;
} }
.bsplus-folder-item.bsplus-dragging {
opacity: 0.4;
}
.bsplus-folder-item.bsplus-drag-over {
background: var(--better-main, #007bff22) !important;
border-radius: 4px;
}
.bsplus-folder-item:hover { .bsplus-folder-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)); background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
} }
@@ -121,18 +76,6 @@
border-radius: 0 2px 2px 0; border-radius: 0 2px 2px 0;
} }
.bsplus-folder-drag {
display: flex;
align-items: center;
opacity: 0;
transition: opacity .15s;
margin-right: -4px;
}
.bsplus-folder-item:hover .bsplus-folder-drag {
opacity: 0.5;
}
.bsplus-folder-dot { .bsplus-folder-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -140,23 +83,6 @@
flex-shrink: 0; flex-shrink: 0;
} }
.bsplus-folder-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-primary, #333);
}
.bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-folder-name { .bsplus-folder-name {
font-size: 13px; font-size: 13px;
color: var(--text-primary, #333); color: var(--text-primary, #333);
@@ -171,8 +97,6 @@
color: var(--text-primary, #999); color: var(--text-primary, #999);
opacity: 0.5; opacity: 0.5;
flex-shrink: 0; flex-shrink: 0;
min-width: 16px;
text-align: right;
} }
.bsplus-folder-actions { .bsplus-folder-actions {
@@ -234,35 +158,6 @@
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2); box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2);
} }
.bsplus-folder-icon-btn {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: 1px solid var(--background-secondary, #ccc) !important;
border-radius: 6px !important;
background: var(--background-secondary, #f5f5f5) !important;
cursor: pointer;
padding: 0 !important;
margin: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-folder-icon-btn:hover {
transform: scale(1.1);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
.bsplus-folder-icon-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-folder-input-confirm, .bsplus-folder-input-confirm,
.bsplus-folder-input-cancel { .bsplus-folder-input-cancel {
display: flex !important; display: flex !important;
@@ -297,43 +192,6 @@
background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important; background: var(--background-secondary, rgba(128, 128, 128, 0.1)) !important;
} }
/* ── Icon picker ── */
.bsplus-icon-picker {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 4px 12px 6px;
max-width: 140px;
}
.bsplus-icon-opt {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 28px !important;
height: 28px !important;
min-width: 0 !important;
border: none !important;
border-radius: 6px !important;
background: transparent !important;
cursor: pointer;
padding: 0 !important;
transition: all .15s;
color: var(--text-primary, #333);
}
.bsplus-icon-opt svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
}
.bsplus-icon-opt:hover {
transform: scale(1.3);
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.1)) !important;
}
/* ── Color picker row ── */ /* ── Color picker row ── */
.bsplus-folder-colors { .bsplus-folder-colors {
display: grid; display: grid;
@@ -464,113 +322,14 @@
opacity: 0.5; opacity: 0.5;
} }
/* ── Context menu ── */ /* ── Let primary column use available space instead of being clipped ── */
.bsplus-context-menu {
position: fixed;
min-width: 160px;
background: var(--background-primary, #fff) !important;
border: 1px solid var(--background-secondary, #e0e0e0) !important;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 2147483646;
overflow: hidden;
animation: bsplus-dropdown-in 0.12s ease-out;
padding: 4px 0;
}
.bsplus-context-title {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-primary, #999) !important;
opacity: 0.5;
user-select: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08)) !important;
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-item {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
gap: 8px;
padding: 7px 12px !important;
font-size: 13px;
cursor: pointer;
border: none !important;
background: transparent !important;
width: 100%;
text-align: left !important;
color: var(--text-primary, #333) !important;
transition: background .1s;
font-family: inherit;
}
.bsplus-context-item .bsplus-folder-icon {
color: var(--text-primary, #333) !important;
width: 16px;
height: 16px;
}
.bsplus-context-item .bsplus-folder-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
}
.bsplus-context-item:hover {
background: var(--theme-offset-bg-more, rgba(128, 128, 128, 0.08));
}
.bsplus-context-item span {
flex: 1;
}
.bsplus-context-checkmark {
color: var(--better-main, #007bff) !important;
font-weight: bold;
flex: 0 !important;
}
.bsplus-context-empty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-primary, #999);
opacity: 0.5;
}
/* ── Drag feedback ── */
.bsplus-msg-dragging {
opacity: 0.4;
}
[class*='MessageList__MessageList___'] ol > li[data-message] {
transition: opacity .15s;
}
/* ── Layout fixes ── */
[class*='MessageList__primary___'] { [class*='MessageList__primary___'] {
flex: 1 1 0% !important; flex: 1 1 0% !important;
min-width: 0 !important; min-width: 0 !important;
overflow: hidden !important; overflow: hidden !important;
} }
/* ── Make subject line a flex row so badges sit inline ── */
[class*='MessageList__subject___'] { [class*='MessageList__subject___'] {
display: flex !important; display: flex !important;
align-items: center; align-items: center;
@@ -579,6 +338,7 @@
overflow: hidden !important; overflow: hidden !important;
} }
/* ── Subject text truncates to make room for badges ── */
.bsplus-subject-text { .bsplus-subject-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -587,6 +347,7 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
/* ── Shrink the secondary column to its content ── */
[class*='MessageList__secondary___'] { [class*='MessageList__secondary___'] {
flex: 0 0 auto !important; flex: 0 0 auto !important;
width: auto !important; width: auto !important;
@@ -594,6 +355,7 @@
max-width: 200px !important; max-width: 200px !important;
} }
/* ── Constrain the flags/attachment icon column ── */
[class*='MessageList__flags___'] { [class*='MessageList__flags___'] {
width: 24px !important; width: 24px !important;
min-width: 0 !important; min-width: 0 !important;
@@ -629,7 +391,7 @@
transform: scale(1.05); transform: scale(1.05);
} }
/* ── Folder filtering ── */ /* ── Folder filtering (hide messages not in active folder) ── */
.bsplus-folder-hidden { .bsplus-folder-hidden {
display: none !important; display: none !important;
} }
@@ -727,5 +489,3 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35); box-shadow: 0 4px 12px rgba(229, 62, 62, 0.35);
} }
+24 -6
View File
@@ -11,12 +11,16 @@ import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import { 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;
@@ -546,10 +550,7 @@ export class ThemeManager {
} }
} }
/** Use a getter so dev-mode session-only base URL overrides take effect immediately. */ private readonly THEME_API_BASE = 'https://betterseqta.org/api';
private get THEME_API_BASE(): string {
return `${getApiBase()}/api`;
}
private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes';
/** /**
@@ -1091,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;
@@ -1104,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);
+19 -92
View File
@@ -145,10 +145,8 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
let observer: MutationObserver | null = null; let observer: MutationObserver | null = null;
let quickbarObserver: MutationObserver | null = null; let quickbarObserver: MutationObserver | null = null;
let quickbarSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastClickedCi: number | null = null; let lastClickedCi: number | null = null;
let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null; let lastClickedEntry: { roomEl: HTMLElement; teacherEl: HTMLElement; item: TimetableEntryData } | null = null;
let lastSyncedQuickbarCi: number | null = null;
const getOverrides = (): TimetableOverrides => const getOverrides = (): TimetableOverrides =>
api.storage.timetableOverrides ?? {}; api.storage.timetableOverrides ?? {};
@@ -188,11 +186,9 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff; if (override.staff !== undefined && teacherEl) teacherEl.textContent = override.staff;
} }
const captureClick = () => { const captureClick = (e: MouseEvent) => {
lastClickedCi = ci; lastClickedCi = ci;
lastClickedEntry = { roomEl, teacherEl, item }; lastClickedEntry = { roomEl, teacherEl, item };
lastSyncedQuickbarCi = null;
scheduleQuickbarSync();
}; };
entry.addEventListener("click", captureClick, true); entry.addEventListener("click", captureClick, true);
}; };
@@ -203,76 +199,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}); });
}; };
const getVisibleClassQuickbar = (): HTMLElement | null => {
const quickbar = document.querySelector(
".timetablepage .quickbar.below.visible, .timetablepage .quickbar.above.visible, .timetablepage .quickbar.visible",
);
if (!quickbar || quickbar.getAttribute("data-type") !== "class") return null;
return quickbar as HTMLElement;
};
const applyOverridesToQuickbar = (quickbar: HTMLElement): void => {
if (lastClickedCi === null) return;
if (lastSyncedQuickbarCi === lastClickedCi) return;
const description =
quickbar.querySelector(".title")?.textContent?.trim() ??
lastClickedEntry?.item.description ??
"";
const override = getEffectiveOverride(lastClickedCi, description);
if (!override) {
lastSyncedQuickbarCi = lastClickedCi;
return;
}
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (override.room !== undefined && !roomEl) return;
if (override.staff !== undefined && !teacherEl) return;
if (override.room !== undefined && roomEl && roomEl.textContent !== override.room) {
roomEl.textContent = override.room;
}
if (override.staff !== undefined && teacherEl && teacherEl.textContent !== override.staff) {
teacherEl.textContent = override.staff;
}
lastSyncedQuickbarCi = lastClickedCi;
};
const updateVisibleQuickbar = (room: string, staff: string): void => {
const quickbar = getVisibleClassQuickbar();
if (!quickbar) return;
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (roomEl && roomEl.textContent !== room) roomEl.textContent = room;
if (teacherEl && teacherEl.textContent !== staff) teacherEl.textContent = staff;
if (lastClickedCi !== null) lastSyncedQuickbarCi = lastClickedCi;
};
const syncClassQuickbar = (quickbar: HTMLElement): void => {
applyOverridesToQuickbar(quickbar);
addEditButtonToQuickbar(quickbar);
};
const scheduleQuickbarSync = (): void => {
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
let attempts = 0;
const trySync = (): void => {
const quickbar = getVisibleClassQuickbar();
if (quickbar && lastClickedCi !== null) {
syncClassQuickbar(quickbar);
return;
}
if (++attempts < 6) {
quickbarSyncTimer = setTimeout(trySync, 50);
}
};
requestAnimationFrame(trySync);
};
const addEditButtonToQuickbar = (quickbar: HTMLElement) => { const addEditButtonToQuickbar = (quickbar: HTMLElement) => {
if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return; if (quickbar.querySelector(".timetable-edit-quickbar-btn")) return;
@@ -325,7 +251,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
} }
if (entryData.roomEl) entryData.roomEl.textContent = room; if (entryData.roomEl) entryData.roomEl.textContent = room;
if (entryData.teacherEl) entryData.teacherEl.textContent = staff; if (entryData.teacherEl) entryData.teacherEl.textContent = staff;
updateVisibleQuickbar(room, staff);
processAllEntries(); processAllEntries();
}, },
(ci) => { (ci) => {
@@ -337,7 +262,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
api.storage.timetableOverridesBySubject = bySubject; api.storage.timetableOverridesBySubject = bySubject;
if (entryData.roomEl) entryData.roomEl.textContent = item.room; if (entryData.roomEl) entryData.roomEl.textContent = item.room;
if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff; if (entryData.teacherEl) entryData.teacherEl.textContent = item.staff;
updateVisibleQuickbar(item.room, item.staff);
processAllEntries(); processAllEntries();
}, },
); );
@@ -347,30 +271,34 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
}; };
const syncQuickbarFromDOM = () => { const syncQuickbarFromDOM = () => {
const quickbar = getVisibleClassQuickbar(); const quickbar = document.querySelector(
if (!quickbar || lastClickedCi === null || !lastClickedEntry) return; ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
syncClassQuickbar(quickbar); );
if (quickbar && quickbar.getAttribute("data-type") === "class") {
const titleEl = quickbar.querySelector(".title");
const roomEl = quickbar.querySelector(".meta .room");
const teacherEl = quickbar.querySelector(".meta .teacher");
if (titleEl && roomEl && teacherEl && lastClickedCi !== null && lastClickedEntry) {
addEditButtonToQuickbar(quickbar as HTMLElement);
}
}
}; };
const setupQuickbarObserver = () => { const setupQuickbarObserver = () => {
const timetablePage = document.querySelector(".timetablepage"); const timetablePage = document.querySelector(".timetablepage");
if (!timetablePage || quickbarObserver) return; if (!timetablePage || quickbarObserver) return;
quickbarObserver = new MutationObserver((mutations) => { quickbarObserver = new MutationObserver(() => {
const quickbarBecameVisible = mutations.some( const quickbar = document.querySelector(
(mutation) => ".timetablepage .quickbar.below.visible, .timetablepage .quickbar.visible",
mutation.type === "attributes" &&
mutation.attributeName === "class" &&
(mutation.target as HTMLElement).classList.contains("quickbar") &&
(mutation.target as HTMLElement).classList.contains("visible"),
); );
if (!quickbarBecameVisible || lastClickedCi === null) return; if (quickbar?.getAttribute("data-type") === "class") {
addEditButtonToQuickbar(quickbar as HTMLElement);
const quickbar = getVisibleClassQuickbar(); }
if (quickbar) syncClassQuickbar(quickbar);
}); });
quickbarObserver.observe(timetablePage, { quickbarObserver.observe(timetablePage, {
childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ["class"], attributeFilter: ["class"],
@@ -408,7 +336,6 @@ const timetableEditPlugin: Plugin<{}, TimetableStorage> = {
unregister(); unregister();
observer?.disconnect(); observer?.disconnect();
quickbarObserver?.disconnect(); quickbarObserver?.disconnect();
if (quickbarSyncTimer !== null) clearTimeout(quickbarSyncTimer);
styleEl.remove(); styleEl.remove();
document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => { document.querySelectorAll("[data-timetable-edit-processed]").forEach((el) => {
el.removeAttribute("data-timetable-edit-processed"); el.removeAttribute("data-timetable-edit-processed");
+2 -2
View File
@@ -10,7 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage";
import profilePicturePlugin from "./built-in/profilePicture"; import profilePicturePlugin from "./built-in/profilePicture";
import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview";
import backgroundMusicPlugin from "./built-in/backgroundMusic"; import backgroundMusicPlugin from "./built-in/backgroundMusic";
import messageFoldersPlugin from "./built-in/messageFolders"; //import messageFoldersPlugin from "./built-in/messageFolders";
//import testPlugin from './built-in/test'; //import testPlugin from './built-in/test';
// Heavy plugins (lazy-loaded only when enabled) // Heavy plugins (lazy-loaded only when enabled)
@@ -29,7 +29,7 @@ pluginManager.registerPlugin(timetableEditPlugin);
pluginManager.registerPlugin(profilePicturePlugin); pluginManager.registerPlugin(profilePicturePlugin);
pluginManager.registerPlugin(assessmentsOverviewPlugin); pluginManager.registerPlugin(assessmentsOverviewPlugin);
pluginManager.registerPlugin(backgroundMusicPlugin); pluginManager.registerPlugin(backgroundMusicPlugin);
pluginManager.registerPlugin(messageFoldersPlugin); //pluginManager.registerPlugin(messageFoldersPlugin);
//pluginManager.registerPlugin(testPlugin); //pluginManager.registerPlugin(testPlugin);
// Register heavy plugins with lazy loading // Register heavy plugins with lazy loading
+4 -4
View File
@@ -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";
@@ -105,11 +105,11 @@ export async function finishLoad() {
console.error("Error during loading cleanup:", err); console.error("Error during loading cleanup:", err);
} }
void runStartupPopupQueue(); runStartupPopupQueue();
} }
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
View File
@@ -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);
} }
+3 -3
View File
@@ -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) {
+28 -22
View File
@@ -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; const rgb = Color.rgb(s).object();
} 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); return Math.sqrt(rgb.r ** 2 + rgb.g ** 2 + rgb.b ** 2);
} }
}
-65
View File
@@ -1,65 +0,0 @@
import browser from "webextension-polyfill";
const DEFAULT_BASE = "https://betterseqta.org";
const KEY = "bsplus_dev_api_base";
/**
* Returns the current content-API base URL.
*
* Reads from `sessionStorage` so a developer can temporarily override the
* server for testing. The value is cleared when the browser session ends,
* leaving production traffic unaffected for normal users.
*/
export function getApiBase(): string {
try {
if (typeof sessionStorage === "undefined") return DEFAULT_BASE;
const v = sessionStorage.getItem(KEY);
if (v && /^https?:\/\//.test(v)) return v.replace(/\/$/, "");
} catch {
// sessionStorage may throw in some restricted contexts; fall back silently.
}
return DEFAULT_BASE;
}
/**
* Persist a session-scoped override and broadcast it to the background script
* so its `fetch` calls hit the same host.
*
* Pass `null` to clear the override.
*/
export function setApiBase(url: string | null): void {
try {
if (!url) {
sessionStorage.removeItem(KEY);
} else {
sessionStorage.setItem(KEY, url.replace(/\/$/, ""));
}
} catch {
// ignore
}
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: url || null })
.catch(() => {});
}
/** Returns the override URL if one is currently set in this session. */
export function getStoredOverride(): string | null {
try {
if (typeof sessionStorage === "undefined") return null;
return sessionStorage.getItem(KEY);
} catch {
return null;
}
}
/**
* Send the current session override to the background script.
* Call this early in page load so the background context stays in sync after
* service-worker restarts.
*/
export function syncApiBaseToBackground(): void {
const override = getStoredOverride();
void browser.runtime
.sendMessage({ type: "setDevApiBase", url: override })
.catch(() => {});
}
@@ -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>`;
} }
+16 -7
View File
@@ -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();
@@ -113,7 +113,16 @@ export async function loadHomePage() {
callHomeTimetable(TodayFormatted, true); callHomeTimetable(TodayFormatted, true);
const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeSubjects = activeClass?.subjects || []; const activeYear = activeClass?.year;
const allSubjectsInYear = classes
.filter((c: any) => c.year === activeYear)
.flatMap((c: any) => c.subjects || []);
const seen = new Set<string>();
const activeSubjects = allSubjectsInYear.filter((s: any) => {
if (seen.has(s.code)) return false;
seen.add(s.code);
return true;
});
const activeSubjectCodes = activeSubjects.map((s: any) => s.code); const activeSubjectCodes = activeSubjects.map((s: any) => s.code);
const currentAssessments = assessments const currentAssessments = assessments
.filter((a: any) => activeSubjectCodes.includes(a.code)) .filter((a: any) => activeSubjectCodes.includes(a.code))
@@ -146,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);
@@ -295,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);
@@ -307,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);
@@ -726,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);
@@ -978,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>`;
} }
@@ -14,14 +14,13 @@ export function showEngageParentsToast() {
settingsState.engageParentsAnnouncementShown = true; settingsState.engageParentsAnnouncementShown = true;
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = "bsplus-toast engageParentsToast"; toast.className = "bsplus-toast";
toast.innerHTML = /* html */ ` toast.innerHTML = /* html */ `
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
<div class="bsplus-toast-content"> <div class="bsplus-toast-content">
<p class="bsplus-toast-eyebrow">SEQTA Engage support</p>
<strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong> <strong>BetterSEQTA+ now supports <span class="seqtaEngageAccent">SEQTA Engage</span></strong>
<p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p> <p>Buy your mum a BetterSEQTA Plus! Parents now get themes, a cleaner home page, and all the Plus polish on SEQTA Engage.</p>
</div> </div>
<button class="bsplus-toast-close" aria-label="Dismiss">&times;</button>
`; `;
toast.style.opacity = "0"; toast.style.opacity = "0";
@@ -1,218 +0,0 @@
import browser from "webextension-polyfill";
import stringToHTML from "../stringToHTML";
import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
/**
* Server response shape from `/api/theme-of-the-month/current`.
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
*/
export interface ThemeOfTheMonthEntry {
id: string;
month: string;
title: string;
description: string;
cover_image: string | null;
theme_id: string | null;
theme: { id: string; name: string; slug: string } | null;
created_at: number;
updated_at: number;
}
/**
* Fetches the current month's Theme of the Month entry from the API.
* Returns `null` when no entry is configured for this month, or when the
* request fails (we never want a network problem to block other startup
* popups).
*/
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
cache: "no-store",
});
if (!res.ok) return null;
const text = await res.text();
if (!text) return null;
const data = JSON.parse(text);
if (!data || typeof data !== "object" || !data.id) return null;
return data as ThemeOfTheMonthEntry;
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch current entry:", err);
return null;
}
}
/** True when we have a new monthly entry the user hasn't dismissed yet. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthLastSeenId !== entry.id;
}
function escapeHTML(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function formatMonthLabel(month: string): string {
const [yyyy, mm] = month.split("-");
if (!yyyy || !mm) return month;
const date = new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, 1);
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null;
coverImage?: string | null;
}): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim();
return url || null;
}
/**
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({
type: "fetchThemeDetails",
themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
if (!res?.success || !res?.data?.theme) return null;
return heroUrlFromStoreTheme(res.data.theme);
} catch (err) {
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
return null;
}
}
/** Linked theme store image, else optional admin-uploaded cover. */
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
const themeId = entry.theme_id ?? entry.theme?.id;
if (themeId) {
const fromStore = await fetchThemeStoreHeroImage(themeId);
if (fromStore) return fromStore;
}
const fallback = entry.cover_image?.trim();
return fallback || null;
}
function closeThemeOfTheMonthCard(
card: HTMLElement,
onDismissed?: () => void,
markSeen = true,
) {
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
if (markSeen) {
const entryId = card.dataset.entryId;
if (entryId) settingsState.themeOfTheMonthLastSeenId = entryId;
}
card.classList.add("themeOfTheMonthCardClosing");
window.setTimeout(() => {
card.remove();
onDismissed?.();
}, 180);
}
/**
* Renders the Theme of the Month announcement card.
*/
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
document.getElementById("theme-of-the-month-card")?.remove();
const monthLabel = formatMonthLabel(entry.month);
const heroUrl = await resolvePopupHeroImageUrl(entry);
const description = escapeHTML(entry.description).replace(/\n/g, " ");
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
<button type="button" class="themeOfTheMonthCardClose" aria-label="Close Theme of the Month">×</button>
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<div class="themeOfTheMonthCardActions">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
<button type="button" class="themeOfTheMonthCardSecondary">Don't show again</button>
</div>
</div>
</aside>
`).firstChild as HTMLElement;
card.dataset.entryId = entry.id;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 12000);
const dismiss = (markSeen = true) => {
window.clearTimeout(autoCloseTimeout);
closeThemeOfTheMonthCard(card, onDismissed, markSeen);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
card.querySelector(".themeOfTheMonthCardClose")?.addEventListener("click", () => {
dismiss();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
dismiss();
openThemeStoreWithHighlight(linkedThemeId!);
});
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
});
document.body.appendChild(card);
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user has already dismissed it this month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {
alert(
"No Theme of the Month entry for the current month (UTC). Create one in the website admin, or check your dev API base URL.",
);
return;
}
settingsState.themeOfTheMonthLastSeenId = undefined;
if (document.getElementById("whatsnewbk")) {
await closePopup();
await new Promise((resolve) => setTimeout(resolve, 150));
}
await OpenThemeOfTheMonthPopup(entry);
}
+1 -8
View File
@@ -33,14 +33,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
const text = stringToHTML(/* html */ ` const text = stringToHTML(/* html */ `
<div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;"> <div class="whatsnewTextContainer" style="height: 50%;overflow-y: auto;">
<h1>3.6.5 - Theme of the Month, custom message folders & assessment weighting overrides</h1>
<li>Added Theme of the Month a monthly featured theme popup with a link to view it in the theme store.</li>
<li>Added custom message folders for organising direct DM's with drag to reorder.</li>
<li>Added the ability to override/add weightings to assessments (on assessment page).</li>
<li>Fixed custom room and teacher names not showing in the timetable popup.</li>
<li>Fixed assessment averages treating N/A weightings incorrectly in subject average calculations.</li>
<li>Fixed the display of weightings that could not automatically be discovered.</li>
<li>Fixed the formatting of the weighting tag that was broken due to a SEQTA update.</li>
<h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1> <h1>3.6.4 - Theme flavours and fixes, Upcoming Assements improvement</h1>
<li>Added advanced colour adjustments variables for theme customisation.</li> <li>Added advanced colour adjustments variables for theme customisation.</li>
+2 -22
View File
@@ -4,40 +4,20 @@ import {
shouldShowEngageParentsAnnouncement, shouldShowEngageParentsAnnouncement,
showEngageParentsToast, showEngageParentsToast,
} from "./OpenEngageParentsAnnouncement"; } from "./OpenEngageParentsAnnouncement";
import {
fetchThemeOfTheMonth,
OpenThemeOfTheMonthPopup,
shouldShowThemeOfTheMonth,
} from "./OpenThemeOfTheMonthPopup";
import { syncApiBaseToBackground } from "../DevApiBase";
type QueueStep = (goNext: () => void) => void; type QueueStep = (goNext: () => void) => void;
/** /**
* Runs startup modals in order: What's New (if the extension just updated), * Runs startup modals in order: What's New (if the extension just updated),
* Theme of the Month (when a new monthly entry hasn't been seen), then shows * then shows the SEQTA Engage toast (once, non-blocking).
* the SEQTA Engage toast (once, non-blocking).
*/ */
export async function runStartupPopupQueue() { export function runStartupPopupQueue() {
// Make sure the background script knows about any dev-mode API override
// before we start firing requests.
syncApiBaseToBackground();
const steps: QueueStep[] = []; const steps: QueueStep[] = [];
if (settingsState.justupdated) { if (settingsState.justupdated) {
steps.push((goNext) => OpenWhatsNewPopup(goNext)); steps.push((goNext) => OpenWhatsNewPopup(goNext));
} }
// Fetch the Theme of the Month before queueing so we don't show an empty
// popup if the network or server is unavailable.
const themeOfTheMonth = await fetchThemeOfTheMonth();
if (shouldShowThemeOfTheMonth(themeOfTheMonth)) {
steps.push((goNext) => {
void OpenThemeOfTheMonthPopup(themeOfTheMonth!, goNext);
});
}
function runNext() { function runNext() {
const step = steps.shift(); const step = steps.shift();
if (step) step(runNext); if (step) step(runNext);
+3 -2
View File
@@ -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%;
`; `;
+31
View File
@@ -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}`;
+14 -1
View File
@@ -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;
+20 -1
View File
@@ -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);
})();
}; };
+21 -1
View File
@@ -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);
}
}
+18
View File
@@ -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}`;
+38
View File
@@ -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;
}
+20 -6
View File
@@ -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;
} }
+10
View File
@@ -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,39 +0,0 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/**
* Module-level handoff for "open the theme store and highlight this theme".
*
* The store page is mounted lazily inside a Shadow DOM the first time it
* opens, so a `CustomEvent` listener would have to be wired up before mount
* (causing a race). Using a shared cell keeps the producer (popup button) and
* consumer (store `onMount`) decoupled without that timing constraint.
*
* The store reads & clears this on mount via {@link consumePendingHighlightThemeId}.
*/
let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId;
pendingHighlightThemeId = null;
return id;
}
/**
* Opens the theme store and asks it to focus / highlight the given theme.
* If the store is already mounted we dispatch a DOM event so it can react
* without remounting; otherwise the store consumes the pending id on mount.
*/
export function openThemeStoreWithHighlight(themeId: string): void {
pendingHighlightThemeId = themeId;
const existing = document.getElementById("store");
if (existing) {
window.dispatchEvent(
new CustomEvent("bsplus:highlight-theme", { detail: { themeId } }),
);
return;
}
OpenStorePage();
}
+49 -17
View File
@@ -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");
} }
-4
View File
@@ -36,10 +36,6 @@ export interface SettingsState {
engageParentsAnnouncementShown?: boolean; engageParentsAnnouncementShown?: boolean;
/** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */ /** One-time announcement: BS Cloud automatic settings sync (last in startup popup queue). */
bsCloudAutoSyncAnnouncementShown?: boolean; bsCloudAutoSyncAnnouncementShown?: boolean;
/** ID of the last Theme of the Month entry shown to the user (shows once per new entry). */
themeOfTheMonthLastSeenId?: string;
/** Permanently disables Theme of the Month startup prompts. */
themeOfTheMonthDisabled?: boolean;
timeFormat?: string; timeFormat?: string;
animations: boolean; animations: boolean;
defaultPage: string; defaultPage: string;
+68
View File
@@ -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,
};
+3
View File
@@ -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(),
+17
View File
@@ -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"
+45
View File
@@ -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()
}
+48
View File
@@ -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)
}
+15
View File
@@ -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('"', "\\\"")
}
+46
View File
@@ -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 0100).
#[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)
}
+28
View File
@@ -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
}
+140
View File
@@ -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);
}
}
+28
View File
@@ -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}}}"#
))
}
+29
View File
@@ -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
}
+18
View File
@@ -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")
}
+62
View File
@@ -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,
}
}
+83
View File
@@ -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 `startend` / `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")
}
+9
View File
@@ -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")
}