Compare commits

..

1 Commits

Author SHA1 Message Date
SethBurkart123 ecc89e864c chore: bump version to 3.7.2 and update changelog 2026-06-19 15:00:29 +10:00
94 changed files with 951 additions and 2121 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betterseqtaplus", "name": "betterseqtaplus",
"version": "3.7.1", "version": "3.7.2",
"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",
+3 -4
View File
@@ -6,7 +6,7 @@ import documentLoadCSS from "@/css/documentload.scss?inline";
import icon48 from "@/resources/icons/icon-48.png?base64"; import icon48 from "@/resources/icons/icon-48.png?base64";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { init as Monofile } from "@/plugins/monofile"; 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";
@@ -104,11 +104,10 @@ async function init() {
} }
await main(); await main();
Monofile(); plugins.Monofile();
if (settingsState.onoff) { if (settingsState.onoff) {
const { initializePlugins } = await import("@/plugins/runtime"); await plugins.initializePlugins();
await initializePlugins();
} }
if (settingsState.devMode) { if (settingsState.devMode) {
+69 -151
View File
@@ -10,9 +10,7 @@ import {
performCloudSettingsUploadWithRetry, performCloudSettingsUploadWithRetry,
requestCloudSettingsDebouncedUpload, requestCloudSettingsDebouncedUpload,
runCloudSettingsPoll, runCloudSettingsPoll,
withSuppressedCloudAutoUpload,
} from "./background/cloudSettingsAutoSync"; } from "./background/cloudSettingsAutoSync";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
/** /**
* Session-only dev-mode override of the content API base. * Session-only dev-mode override of the content API base.
@@ -47,11 +45,6 @@ function reloadSeqtaPages() {
/** Callback for sending a response back to the message sender */ /** Callback for sending a response back to the message sender */
type MessageSender = { (response?: unknown): void }; type MessageSender = { (response?: unknown): void };
async function getAccessTokenFromStorage(): Promise<string | null> {
const { bsplus_token } = await browser.storage.local.get("bsplus_token");
return typeof bsplus_token === "string" && bsplus_token.length > 0 ? bsplus_token : null;
}
/** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */ /** Accept API + GitHub fallback shapes; always return `{ success, data?: { themes } }`. */
function normalizeFetchThemesResponse(json: unknown): { function normalizeFetchThemesResponse(json: unknown): {
success: boolean; success: boolean;
@@ -86,101 +79,66 @@ function normalizeFetchThemesResponse(json: unknown): {
} }
function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { function handleFetchThemes(request: any, sendResponse: MessageSender): boolean {
void (async () => { const { token } = request;
const token = await getAccessTokenFromStorage(); const apiUrl = `${apiBase()}/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`;
const apiUrl = `${apiBase()}/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}`; fetch(apiUrl, { cache: "no-store", headers })
fetch(apiUrl, { cache: "no-store", headers }) .then(async (r) => {
.then(async (r) => { const json = await r.json();
const json = await r.json(); if (!r.ok) {
if (!r.ok) { throw new Error(
throw new Error( (json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string"
(json && typeof json === "object" && "error" in json && typeof (json as { error?: string }).error === "string" ? (json as { error: string }).error
? (json as { error: string }).error : null) ?? `Themes API HTTP ${r.status}`,
: null) ?? `Themes API HTTP ${r.status}`, );
); }
} return normalizeFetchThemesResponse(json);
return normalizeFetchThemesResponse(json); })
}) .then(sendResponse)
.then(sendResponse) .catch((err) => {
.catch((err) => { console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message);
console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); fetch(githubUrl, { cache: "no-store" })
fetch(githubUrl, { cache: "no-store" }) .then(async (r) => {
.then(async (r) => { if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`);
if (!r.ok) throw new Error(`GitHub fallback HTTP ${r.status}`); const data = await r.json();
const data = await r.json(); const themes = Array.isArray(data) ? data : (data?.themes ?? []);
const themes = Array.isArray(data) ? data : (data?.themes ?? []); return normalizeFetchThemesResponse({ success: true, data: { themes } });
return normalizeFetchThemesResponse({ success: true, data: { themes } }); })
}) .then(sendResponse)
.then(sendResponse) .catch((fallbackErr) => {
.catch((fallbackErr) => { console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr);
console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); sendResponse({ success: false, error: fallbackErr?.message });
sendResponse({ success: false, error: fallbackErr?.message }); });
}); });
});
})();
return true; return true;
} }
function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean { function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean {
const { themeId } = request; const { themeId, token } = request;
if (!themeId || typeof themeId !== "string") { if (!themeId || typeof themeId !== "string") {
sendResponse({ success: false, error: "Missing themeId" }); sendResponse({ success: false, error: "Missing themeId" });
return false; return false;
} }
void (async () => { const headers: Record<string, string> = {};
const token = await getAccessTokenFromStorage(); if (token) headers["Authorization"] = `Bearer ${token}`;
const headers: Record<string, string> = {}; fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers })
if (token) headers["Authorization"] = `Bearer ${token}`; .then((r) => r.json())
fetch(`${apiBase()}/api/themes/${themeId}`, { cache: "no-store", headers }) .then(sendResponse)
.then((r) => r.json()) .catch((err) => {
.then(sendResponse) console.error("[Background] fetchThemeDetails error:", err);
.catch((err) => { sendResponse({ success: false, error: err?.message });
console.error("[Background] fetchThemeDetails error:", err); });
sendResponse({ success: false, error: err?.message });
});
})();
return true; return true;
} }
function isTrustedSender(sender?: browser.Runtime.MessageSender): boolean { function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean {
if (!sender) return false;
if (sender.id && sender.id !== browser.runtime.id) return false;
const urls = [sender.url, sender.tab?.url].filter(Boolean) as string[];
for (const pageUrl of urls) {
if (/^chrome-extension:\/\//.test(pageUrl) || /^moz-extension:\/\//.test(pageUrl)) {
return true;
}
try {
if (isSeqtaOrigin(new URL(pageUrl).origin)) return true;
} catch {
// try next URL
}
}
return false;
}
function handleFetchFromUrl(
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
const { url } = request; const { url } = request;
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
sendResponse({ error: "Missing url" }); sendResponse({ error: "Missing url" });
return false; return false;
} }
if (!isAllowedFetchUrl(url)) {
sendResponse({ error: "URL not allowed" });
return false;
}
fetch(url, { cache: "no-store" }) fetch(url, { cache: "no-store" })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => sendResponse({ data })) .then((data) => sendResponse({ data }))
@@ -219,15 +177,7 @@ function handleCloudReserveClient(request: any, sendResponse: MessageSender): bo
return true; return true;
} }
function handleCloudLogin( function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
const { client_id, redirect_uri, login, password } = request; const { client_id, redirect_uri, login, password } = request;
if (!client_id || !redirect_uri || !login || !password) { if (!client_id || !redirect_uri || !login || !password) {
sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); sendResponse({ error: "Missing client_id, redirect_uri, login, or password" });
@@ -341,18 +291,10 @@ function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean
return true; return true;
} }
function handleCloudSettingsUpload( function handleCloudSettingsUpload(request: any, sendResponse: MessageSender): boolean {
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ success: false, error: "Unauthorized sender" });
return false;
}
void (async () => { void (async () => {
try { try {
const token = await getAccessTokenFromStorage(); const token = request.token as string | undefined;
if (!token) { if (!token) {
sendResponse({ success: false, error: "Not authenticated" }); sendResponse({ success: false, error: "Not authenticated" });
return; return;
@@ -374,18 +316,10 @@ function handleCloudSettingsUpload(
return true; return true;
} }
function handleCloudSettingsDownload( function handleCloudSettingsDownload(request: any, sendResponse: MessageSender): boolean {
request: any,
sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) {
sendResponse({ success: false, error: "Unauthorized sender" });
return false;
}
void (async () => { void (async () => {
try { try {
const token = await getAccessTokenFromStorage(); const token = request.token as string | undefined;
if (!token) { if (!token) {
sendResponse({ success: false, error: "Not authenticated" }); sendResponse({ success: false, error: "Not authenticated" });
return; return;
@@ -409,29 +343,22 @@ function handleCloudSettingsDownload(
} }
function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean {
const { themeId, action } = request; const { themeId, token, action } = request;
if (!themeId) { if (!themeId || !token) {
sendResponse({ success: false, error: "Theme ID required" }); sendResponse({ success: false, error: "Theme ID and token required" });
return false; return false;
} }
void (async () => { const isFavorite = action === "favorite";
const token = await getAccessTokenFromStorage(); fetch(`${apiBase()}/api/themes/${themeId}/favorite`, {
if (!token) { method: isFavorite ? "POST" : "DELETE",
sendResponse({ success: false, error: "Not authenticated" }); headers: { Authorization: `Bearer ${token}` },
return; })
} .then((r) => r.json())
const isFavorite = action === "favorite"; .then(sendResponse)
fetch(`${apiBase()}/api/themes/${themeId}/favorite`, { .catch((err) => {
method: isFavorite ? "POST" : "DELETE", console.error("[Background] cloudFavorite error:", err);
headers: { Authorization: `Bearer ${token}` }, sendResponse({ success: false, error: err?.message });
}) });
.then((r) => r.json())
.then(sendResponse)
.catch((err) => {
console.error("[Background] cloudFavorite error:", err);
sendResponse({ success: false, error: err?.message });
});
})();
return true; return true;
} }
@@ -449,12 +376,7 @@ function isSeqtaOrigin(origin: string): boolean {
} }
} }
function handleSetDevApiBase( function handleSetDevApiBase(request: any): boolean {
request: any,
_sendResponse: MessageSender,
sender?: browser.Runtime.MessageSender,
): boolean {
if (!isTrustedSender(sender)) return false;
const url = typeof request?.url === "string" ? request.url.trim() : null; const url = typeof request?.url === "string" ? request.url.trim() : null;
if (url && /^https?:\/\//.test(url)) { if (url && /^https?:\/\//.test(url)) {
DEV_API_BASE = url.replace(/\/$/, ""); DEV_API_BASE = url.replace(/\/$/, "");
@@ -493,11 +415,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
}); });
return true; return true;
}, },
sendNews: (req, sendResponse, sender) => { sendNews: (req, sendResponse) => {
if (!isTrustedSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
fetchNews(req.source ?? "australia", sendResponse); fetchNews(req.source ?? "australia", sendResponse);
return true; return true;
}, },
@@ -574,10 +492,10 @@ function getDefaultValues(): SettingsState {
return getDefaultSettingsState(); return getDefaultSettingsState();
} }
function SetStorageValue(object: SettingsState) { function SetStorageValue(object: any) {
void withSuppressedCloudAutoUpload(() => for (var i in object) {
browser.storage.local.set(object as Record<string, unknown>), browser.storage.local.set({ [i]: object[i] });
); }
} }
/** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */ /** One-time migration for 3.6.5: opt upgraders into Global Search + indexing + transparency defaults. */
+5 -24
View File
@@ -25,11 +25,6 @@ const REFRESH_URL = `${ACCOUNTS_BASE}/api/bsplus/refresh`;
const UPLOAD_DEBOUNCE_MS = 2000; const UPLOAD_DEBOUNCE_MS = 2000;
const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000; const POLL_THROTTLE_MS = 24 * 60 * 60 * 1000;
const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll"; const POLL_THROTTLE_KEY = "bsplus_lastCloudPoll";
const FETCH_TIMEOUT_MS = 30_000;
function fetchWithTimeout(url: string, init?: RequestInit): Promise<Response> {
return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
}
type CloudSummaryResponse = { type CloudSummaryResponse = {
desqta?: unknown; desqta?: unknown;
@@ -40,7 +35,6 @@ let reloadSeqtaPagesFn: (() => void) | null = null;
let suppressAutoUploadDuringRestore = false; let suppressAutoUploadDuringRestore = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pollInFlight: Promise<void> | null = null; let pollInFlight: Promise<void> | null = null;
let autoSyncInitialized = false;
function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean { function isAutoCloudSyncEnabled(all: Record<string, unknown>): boolean {
return all.autoCloudSettingsSync !== false; return all.autoCloudSettingsSync !== false;
@@ -71,7 +65,7 @@ async function tryRefreshTokens(): Promise<boolean> {
if (!refresh_token || !client_id) return false; if (!refresh_token || !client_id) return false;
try { try {
const r = await fetchWithTimeout(REFRESH_URL, { const r = await fetch(REFRESH_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token, client_id }), body: JSON.stringify({ refresh_token, client_id }),
@@ -106,7 +100,7 @@ async function fetchCloudSummaryOnce(
| { ok: false; unauthorized: boolean; error?: string } | { ok: false; unauthorized: boolean; error?: string }
> { > {
try { try {
const r = await fetchWithTimeout(CLOUD_SUMMARY_URL, { const r = await fetch(CLOUD_SUMMARY_URL, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
cache: "no-store", cache: "no-store",
}); });
@@ -183,7 +177,7 @@ async function putSettingsOnce(token: string): Promise<PutResult> {
return { ok: true, skipped: true }; return { ok: true, skipped: true };
} }
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "PUT", method: "PUT",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -241,7 +235,7 @@ type GetResult =
async function getSettingsAndApplyOnce(token: string): Promise<GetResult> { async function getSettingsAndApplyOnce(token: string): Promise<GetResult> {
try { try {
const r = await fetchWithTimeout(CLOUD_SETTINGS_SYNC_URL, { const r = await fetch(CLOUD_SETTINGS_SYNC_URL, {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
cache: "no-store", cache: "no-store",
@@ -379,8 +373,8 @@ export function runCloudSettingsPoll(): Promise<void> {
try { try {
const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY); const { [POLL_THROTTLE_KEY]: last } = await browser.storage.local.get(POLL_THROTTLE_KEY);
if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return; if (Date.now() - (Number(last) || 0) < POLL_THROTTLE_MS) return;
await runCloudSettingsPollInner();
await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() }); await browser.storage.local.set({ [POLL_THROTTLE_KEY]: Date.now() });
await runCloudSettingsPollInner();
} catch (e) { } catch (e) {
console.error("[BS+ cloud sync] Poll error:", e); console.error("[BS+ cloud sync] Poll error:", e);
} finally { } finally {
@@ -457,21 +451,8 @@ function onStorageChanged(
})(); })();
} }
export async function withSuppressedCloudAutoUpload<T>(
operation: () => T | Promise<T>,
): Promise<T> {
suppressAutoUploadDuringRestore = true;
try {
return await operation();
} finally {
suppressAutoUploadDuringRestore = false;
}
}
export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void { export function initCloudSettingsAutoSync(deps: { reloadSeqtaPages: () => void }): void {
reloadSeqtaPagesFn = deps.reloadSeqtaPages; reloadSeqtaPagesFn = deps.reloadSeqtaPages;
if (autoSyncInitialized) return;
autoSyncInitialized = true;
browser.storage.onChanged.addListener(onStorageChanged); browser.storage.onChanged.addListener(onStorageChanged);
} }
+6 -20
View File
@@ -1,7 +1,5 @@
import Parser from "rss-parser"; import Parser from "rss-parser";
const MAX_RATE_LIMIT_RETRIES = 3;
/** /**
* Fetches news articles specifically for Australia from the NewsAPI. * Fetches news articles specifically for Australia from the NewsAPI.
* *
@@ -15,23 +13,15 @@ const MAX_RATE_LIMIT_RETRIES = 3;
* to send the fetched news data back to the caller. * to send the fetched news data back to the caller.
* It's called with an object like `{ news: responseData }`. * It's called with an object like `{ news: responseData }`.
*/ */
const fetchAustraliaNews = async ( const fetchAustraliaNews = async (url: string, sendResponse: any) => {
url: string,
sendResponse: any,
rateLimitRetryCount = 0,
) => {
fetch(url) fetch(url)
.then((result) => result.json()) .then((result) => result.json())
.then((response) => { .then((response) => {
if (response.code == "rateLimited" && rateLimitRetryCount < MAX_RATE_LIMIT_RETRIES) { if (response.code == "rateLimited") {
fetchAustraliaNews(`${url}%00`, sendResponse, rateLimitRetryCount + 1); fetchAustraliaNews((url += "%00"), sendResponse);
} else { } else {
sendResponse({ news: response }); sendResponse({ news: response });
} }
})
.catch((error) => {
console.error("[BetterSEQTA+] Failed to fetch Australia news", error);
sendResponse({ news: { articles: [] } });
}); });
}; };
@@ -109,14 +99,13 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
if (normalizedSource === "australia") { if (normalizedSource === "australia") {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() - 5);
const from = const from =
date.getFullYear() + date.getFullYear() +
"-" + "-" +
String(date.getMonth() + 1).padStart(2, "0") + (date.getMonth() + 1) +
"-" + "-" +
String(date.getDate()).padStart(2, "0"); (date.getDate() - 5);
const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`; const url = `https://newsapi.org/v2/everything?domains=abc.net.au&from=${from}&apiKey=17c0da766ba347c89d094449504e3080`;
fetchAustraliaNews(url, sendResponse); fetchAustraliaNews(url, sendResponse);
@@ -126,6 +115,7 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
const parser = new Parser(); const parser = new Parser();
let feeds: string[]; let feeds: string[];
console.log("fetchNews", normalizedSource);
if (rssFeedsByCountry[normalizedSource.toLowerCase()]) { if (rssFeedsByCountry[normalizedSource.toLowerCase()]) {
feeds = rssFeedsByCountry[normalizedSource.toLowerCase()]; feeds = rssFeedsByCountry[normalizedSource.toLowerCase()];
@@ -139,10 +129,6 @@ export async function fetchNews(source: string | undefined, sendResponse: any) {
const articlesPromises = feeds.map(async (feedUrl) => { const articlesPromises = feeds.map(async (feedUrl) => {
try { try {
const response = await fetch(feedUrl); const response = await fetch(feedUrl);
if (!response.ok) {
console.error(`Failed to fetch RSS feed: ${feedUrl} (${response.status})`);
return [];
}
const feedString = await response.text(); const feedString = await response.text();
const feed = await parser.parseString(feedString); const feed = await parser.parseString(feedString);
+1 -1
View File
@@ -92,7 +92,7 @@
bind:this={background} bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50" class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick} onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick(e as unknown as MouseEvent) }} onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
> >
<div <div
bind:this={content} bind:this={content}
@@ -22,13 +22,15 @@
}); });
async function upload() { async function upload() {
if (!cloudState.isLoggedIn) return; const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true; busy = true;
statusError = null; statusError = null;
statusMessage = null; statusMessage = null;
try { try {
const res = (await browser.runtime.sendMessage({ const res = (await browser.runtime.sendMessage({
type: "cloudSettingsUpload", type: "cloudSettingsUpload",
token,
})) as { success?: boolean; error?: string }; })) as { success?: boolean; error?: string };
if (res?.success) { if (res?.success) {
statusMessage = "Settings uploaded."; statusMessage = "Settings uploaded.";
@@ -47,13 +49,15 @@
} }
async function confirmDownload() { async function confirmDownload() {
if (!cloudState.isLoggedIn) return; const token = await cloudAuth.getStoredToken();
if (!token) return;
busy = true; busy = true;
statusError = null; statusError = null;
statusMessage = null; statusMessage = null;
try { try {
const res = (await browser.runtime.sendMessage({ const res = (await browser.runtime.sendMessage({
type: "cloudSettingsDownload", type: "cloudSettingsDownload",
token,
})) as { success?: boolean; error?: string; notFound?: boolean }; })) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) { if (res?.success) {
statusMessage = "Settings restored."; statusMessage = "Settings restored.";
+1 -3
View File
@@ -19,7 +19,6 @@
let editor = $state<HTMLDivElement | null>(null) let editor = $state<HTMLDivElement | null>(null)
let view: EditorView | null = null; let view: EditorView | null = null;
let unsubSettings: (() => void) | undefined;
let editorTheme = new Compartment(); let editorTheme = new Compartment();
let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>() let { value, onChange, className } = $props<{value: string, onChange: (value: string) => void, className?: string}>()
@@ -74,7 +73,7 @@
view = createEditorView(state, editor as HTMLElement); view = createEditorView(state, editor as HTMLElement);
} }
unsubSettings = settingsState.subscribe((settings) => { settingsState.subscribe((settings) => {
if (view) { if (view) {
view.dispatch({ view.dispatch({
effects: editorTheme.reconfigure( effects: editorTheme.reconfigure(
@@ -86,7 +85,6 @@
}); });
onDestroy(() => { onDestroy(() => {
unsubSettings?.();
if (view) { if (view) {
view.destroy(); view.destroy();
} }
+22 -40
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import type { Component } from 'svelte' import ColourPicker from './ColourPicker.tsx';
import ReactAdapter from './utils/ReactAdapter.svelte';
import { animate } from 'motion'; import { animate } from 'motion';
import { delay } from '@/seqta/utils/delay.ts' import { delay } from '@/seqta/utils/delay.ts'
@@ -14,19 +15,6 @@
let background = $state<HTMLDivElement | null>(null); let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null); let content = $state<HTMLDivElement | null>(null);
let ReactAdapter = $state<Component | null>(null);
let ColourPickerEl = $state<unknown>(null);
let pickerReady = $state(false);
const loadPicker = async () => {
const [adapterMod, pickerMod] = await Promise.all([
import('./utils/ReactAdapter.svelte'),
import('./ColourPicker.tsx'),
]);
ReactAdapter = adapterMod.default;
ColourPickerEl = pickerMod.default;
pickerReady = true;
};
const closePicker = async () => { const closePicker = async () => {
if (standalone) return; if (standalone) return;
@@ -49,30 +37,28 @@
); );
await delay(400); await delay(400);
hidePicker?.(); hidePicker();
} }
onMount(() => { onMount(() => {
void loadPicker().then(() => { if (standalone) return;
if (standalone) return; if (!background || !content) return;
if (!background || !content) return;
animate( animate(
background, background,
{ opacity: [0, 1] }, { opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] } { duration: 0.3, ease: [0.4, 0, 0.2, 1] }
); );
animate( animate(
content, content,
{ scale: [0.4, 1], opacity: [0, 1] }, { scale: [0.4, 1], opacity: [0, 1] },
{ {
type: 'spring', type: 'spring',
stiffness: 400, stiffness: 400,
damping: 30 damping: 30
} }
); );
});
const handleEscapeKey = (e: KeyboardEvent) => { const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -96,9 +82,7 @@
{#if standalone} {#if standalone}
<div class="h-auto overflow-clip rounded-xl"> <div class="h-auto overflow-clip rounded-xl">
{#if pickerReady && ReactAdapter && ColourPickerEl} <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
</div> </div>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -106,15 +90,13 @@
bind:this={background} bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30" class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full shadow-2xl cursor-pointer bg-black/20 border border-[#DDDDDD]/30 dark:border-[#38373D]/30"
onclick={handleBackgroundClick} onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === 'Enter') handleBackgroundClick(e as unknown as MouseEvent) }} onkeydown={(e) => { e.key === 'Enter' && handleBackgroundClick }}
> >
<div <div
bind:this={content} bind:this={content}
class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700" class="p-4 h-auto bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
> >
{#if pickerReady && ReactAdapter && ColourPickerEl} <ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPicker} />
<ReactAdapter customOnChange={customOnChange} customState={customState} savePresets={savePresets} el={ColourPickerEl} />
{/if}
</div> </div>
</div> </div>
{/if} {/if}
+8 -14
View File
@@ -78,7 +78,7 @@
const startRecording = () => { const startRecording = () => {
isRecording = true; isRecording = true;
recordedKeys = new Set(); recordedKeys.clear();
inputElement?.focus(); inputElement?.focus();
}; };
@@ -87,7 +87,7 @@
if (recordedKeys.has('esc')) { if (recordedKeys.has('esc')) {
onChange(''); onChange('');
isRecording = false; isRecording = false;
recordedKeys = new Set(); recordedKeys.clear();
inputElement?.blur(); inputElement?.blur();
return; return;
} }
@@ -113,16 +113,10 @@
} }
isRecording = false; isRecording = false;
recordedKeys = new Set(); recordedKeys.clear();
inputElement?.blur(); inputElement?.blur();
}; };
const addRecordedKey = (key: string) => {
const next = new Set(recordedKeys);
next.add(key);
recordedKeys = next;
};
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isRecording) return; if (!isRecording) return;
@@ -132,14 +126,14 @@
const key = formatKeyForHotkey(e.key); const key = formatKeyForHotkey(e.key);
// Add modifiers // Add modifiers
if (e.ctrlKey) addRecordedKey('ctrl'); if (e.ctrlKey) recordedKeys.add('ctrl');
if (e.metaKey) addRecordedKey('cmd'); if (e.metaKey) recordedKeys.add('cmd');
if (e.altKey) addRecordedKey('alt'); if (e.altKey) recordedKeys.add('alt');
if (e.shiftKey) addRecordedKey('shift'); if (e.shiftKey) recordedKeys.add('shift');
// Add the main key (ignore modifier keys themselves) // Add the main key (ignore modifier keys themselves)
if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) { if (!['ctrl', 'cmd', 'alt', 'shift'].includes(key)) {
addRecordedKey(key); recordedKeys.add(key);
} }
// Auto-stop recording if we have a main key // Auto-stop recording if we have a main key
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
let { state = $bindable(), onChange, min = 0, max = 100, step = 1 } = $props<{ let { state, onChange, min = 0, max = 100, step = 1 } = $props<{
state: number, state: number,
onChange: (value: number) => void, onChange: (value: number) => void,
min?: number, min?: number,
+18 -16
View File
@@ -38,8 +38,8 @@
}); });
</script> </script>
<div class="flex flex-col h-full min-h-0"> <div class="flex flex-col h-full">
<div class="top-0 z-10 shrink-0 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container" role="tablist"> <div class="top-0 z-10 text-[0.875rem] pb-0.5 mx-4 px-2 tab-width-container">
<div bind:this={containerRef} class="flex relative"> <div bind:this={containerRef} class="flex relative">
<MotionDiv <MotionDiv
class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width" class="absolute top-0 left-0 z-0 h-full bg-gradient-to-tr dark:from-[#38373D]/80 dark:to-[#38373D] from-[#DDDDDD]/80 to-[#DDDDDD] rounded-full opacity-40 tab-width"
@@ -48,8 +48,6 @@
/> />
{#each tabs as { title }, index} {#each tabs as { title }, index}
<button <button
role="tab"
aria-selected={activeTab === index}
class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none" class="relative z-10 flex-1 px-4 py-2 focus-visible:outline-none"
onclick={() => activeTab = index} onclick={() => activeTab = index}
> >
@@ -58,17 +56,21 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="overflow-hidden px-4 flex-1 min-h-0"> <div class="overflow-hidden px-4 h-full">
{#each tabs as { Content, props }, index (index)} <MotionDiv
{#if activeTab === index} class="h-full"
<div animate={{ x: `${-activeTab * 100}%` }}
role="tabpanel" transition={springTransition}
class="focus:outline-none w-full h-full min-h-0 pt-2 overflow-y-auto no-scrollbar pb-6 tab active" >
> <div class="flex">
<div class="sticky top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div> {#each tabs as { Content, props }, index}
<Content {...props} /> <div class="absolute focus:outline-none w-full pt-2 transition-opacity duration-300 overflow-y-scroll no-scrollbar pb-2 h-full tab {activeTab === index ? 'opacity-100 active' : 'opacity-0'}"
</div> style="left: {index * 100}%;">
{/if} <div style="left: {index * 100}%;" class="fixed top-0 w-full h-8 bg-gradient-to-b to-transparent pointer-events-none z-[100] from-white dark:from-zinc-800 dark:to-transparent"></div>
{/each} <Content {...props} />
</div>
{/each}
</div>
</MotionDiv>
</div> </div>
</div> </div>
+2 -5
View File
@@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import logo from '@/resources/icons/betterseqta-dark-full.png'; import logo from '@/resources/icons/betterseqta-dark-full.png';
import logoDark from '@/resources/icons/betterseqta-light-full.png'; import logoDark from '@/resources/icons/betterseqta-light-full.png';
import { closeStore } from '@/seqta/ui/renderStore'
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import CloudHeader from './CloudHeader.svelte'; import CloudHeader from './CloudHeader.svelte';
const handleCloseStore = () => {
void import('@/seqta/ui/renderStore').then((module) => module.closeStore());
};
// Props // Props
let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{
searchTerm: string, searchTerm: string,
@@ -67,7 +64,7 @@
<!-- Close Button --> <!-- Close Button -->
<button <button
onclick={handleCloseStore} onclick={closeStore}
class="p-1 px-3" class="p-1 px-3"
> >
<span class="text-2xl font-IconFamily">&#xed8a;</span> <span class="text-2xl font-IconFamily">&#xed8a;</span>
@@ -12,18 +12,18 @@
<div <div
onclick={onClick} onclick={onClick}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick() }} onkeydown={onClick}
tabindex="0" tabindex="-1"
role="button" role="button"
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}" class="relative w-16 h-16 cursor-pointer rounded-xl transition ring-3 dark:ring-zinc-500/50 ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-4 ring-4' : 'ring-0'}"
> >
{#if isEditMode} {#if isEditMode}
<div <div
tabindex="0" tabindex="-1"
role="button" role="button"
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center" class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
onclick={(e) => { e.stopPropagation(); onDelete() }} onclick={onDelete}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); onDelete() } }} onkeydown={onDelete}
> >
<div class="w-4 h-0.5 bg-white"></div> <div class="w-4 h-0.5 bg-white"></div>
</div> </div>
@@ -174,19 +174,18 @@
if (parentElement) { if (parentElement) {
observer = new MutationObserver(checkActiveClass); observer = new MutationObserver(checkActiveClass);
observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] }); observer.observe(parentElement, { attributes: true, attributeFilter: ['class'] });
}
return () => { return () => {
observer?.disconnect(); observer.disconnect();
backgroundUpdates.removeListener(syncBackgrounds); backgroundUpdates.removeListener(syncBackgrounds);
}; };
}
}); });
onDestroy(() => { onDestroy(() => {
observer?.disconnect(); if (observer) {
backgrounds.forEach((bg) => { observer.disconnect();
if (bg.url) URL.revokeObjectURL(bg.url); }
});
}); });
</script> </script>
@@ -2,6 +2,8 @@
import type { CustomTheme, ThemeList } from '@/types/CustomThemes' import type { CustomTheme, ThemeList } from '@/types/CustomThemes'
import { onDestroy, onMount } from 'svelte' import { onDestroy, onMount } from 'svelte'
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import { OpenThemeCreator } from '@/plugins/built-in/themes/ThemeCreator'
import { OpenStorePage } from '@/seqta/ui/renderStore'
import { themeUpdates } from '@/interface/hooks/ThemeUpdates' import { themeUpdates } from '@/interface/hooks/ThemeUpdates'
import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup' import { closeExtensionPopup } from '@/seqta/utils/Closers/closeExtensionPopup'
import { ThemeManager } from '@/plugins/built-in/themes/theme-manager' import { ThemeManager } from '@/plugins/built-in/themes/theme-manager'
@@ -19,14 +21,11 @@
let prevLoggedIn = $state(false); let prevLoggedIn = $state(false);
let showSignInModal = $state(false); let showSignInModal = $state(false);
$effect(() => { cloudAuth.subscribe((s) => {
const unsub = cloudAuth.subscribe((s) => { const now = s.isLoggedIn;
const now = s.isLoggedIn; if (now && !prevLoggedIn && themes) void fetchThemes();
if (now && !prevLoggedIn && themes) void fetchThemes(); prevLoggedIn = now;
prevLoggedIn = now; cloudLoggedIn = now;
cloudLoggedIn = now;
});
return unsub;
}); });
const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => { const handleThemeClick = async (theme: CustomTheme, e: MouseEvent) => {
@@ -103,14 +102,17 @@
selectedTheme: themeManager.getSelectedThemeId() || '', selectedTheme: themeManager.getSelectedThemeId() || '',
} }
if (themes && cloudLoggedIn) { if (themes && cloudLoggedIn) {
const status: Record<string, boolean> = {}; const token = await cloudAuth.getStoredToken();
await Promise.all( if (token) {
themes.themes.map(async (t) => { const status: Record<string, boolean> = {};
try { await Promise.all(
const res = (await browser.runtime.sendMessage({ themes.themes.map(async (t) => {
type: 'fetchThemeDetails', try {
themeId: t.id, const res = (await browser.runtime.sendMessage({
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } }; type: 'fetchThemeDetails',
themeId: t.id,
token,
})) as { success?: boolean; data?: { theme?: { is_favorited?: boolean } } };
if (res?.success && res?.data?.theme) { if (res?.success && res?.data?.theme) {
status[t.id] = !!res.data.theme.is_favorited; status[t.id] = !!res.data.theme.is_favorited;
} }
@@ -120,32 +122,25 @@
}) })
); );
favoriteStatus = status; favoriteStatus = status;
}
} else { } else {
favoriteStatus = {}; favoriteStatus = {};
} }
} }
const openStorePage = async () => {
const { OpenStorePage } = await import('@/seqta/ui/renderStore')
OpenStorePage()
}
const openThemeCreator = async (themeId?: string) => {
const { OpenThemeCreator } = await import('@/plugins/built-in/themes/ThemeCreator')
OpenThemeCreator(themeId)
closeExtensionPopup()
}
const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => { const handleToggleFavorite = async (theme: CustomTheme, e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!cloudLoggedIn) { if (!cloudLoggedIn) {
showSignInModal = true; showSignInModal = true;
return; return;
} }
const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !favoriteStatus[theme.id]; const isFavorite = !favoriteStatus[theme.id];
const result = (await browser.runtime.sendMessage({ const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite', type: 'cloudFavorite',
themeId: theme.id, themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite', action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean }; })) as { success?: boolean };
if (result?.success) { if (result?.success) {
@@ -221,8 +216,8 @@
</div> </div>
<div <div
class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2" class="absolute z-20 flex w-8 h-8 p-2 text-white transition-all rounded-full delay-[20ms] opacity-0 top-1/4 right-2 bg-black/50 place-items-center group-hover:opacity-100 group-hover:top-1/2 -translate-y-1/2"
onclick={(event) => { event.stopPropagation(); void openThemeCreator(theme.id) }} onclick={(event) => { event.stopPropagation(); OpenThemeCreator(theme.id); closeExtensionPopup() }}
onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') void openThemeCreator(theme.id) }} onkeydown={(event) => { if (event.key === 'Enter' || event.key === ' ') OpenThemeCreator(theme.id); closeExtensionPopup() }}
role="button" role="button"
tabindex="-1" tabindex="-1"
> >
@@ -270,7 +265,7 @@
{/if} {/if}
<button <button
onclick={() => void openStorePage()} onclick={() => OpenStorePage()}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white" class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
> >
<span class="text-xl font-IconFamily">&#xecc5;</span> <span class="text-xl font-IconFamily">&#xecc5;</span>
@@ -278,7 +273,7 @@
</button> </button>
<button <button
onclick={() => void openThemeCreator()} onclick={() => { OpenThemeCreator(); closeExtensionPopup() }}
class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white" class="flex justify-center items-center w-full rounded-xl transition aspect-theme bg-zinc-100 dark:bg-zinc-900 dark:text-white"
> >
<span class="text-xl font-IconFamily">&#xec60;</span> <span class="text-xl font-IconFamily">&#xec60;</span>
@@ -1,25 +1,21 @@
<script lang="ts"> <script lang="ts">
import React from "react"; import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { onDestroy } from "svelte"; import { onDestroy, onMount } from "svelte";
const e = React.createElement; const e = React.createElement;
let adapterProps = $props(); let container: HTMLDivElement;
let container = $state<HTMLDivElement | null>(null);
$effect(() => { onMount(() => {
if (!container) return; const { el, children, class: _, ...props } = $$props;
const { el, children, class: className, ...rest } = adapterProps;
try { try {
ReactDOM.render(e(el, rest, children), container); ReactDOM.render(e(el, props, children), container);
} catch (err) { } catch (err) {
console.warn(`react-adapter failed to mount.`, { err }); console.warn(`react-adapter failed to mount.`, { err });
} }
}); });
onDestroy(() => { onDestroy(() => {
if (!container) return;
try { try {
ReactDOM.unmountComponentAtNode(container); ReactDOM.unmountComponentAtNode(container);
} catch (err) { } catch (err) {
@@ -28,4 +24,4 @@
}); });
</script> </script>
<div bind:this={container} class={adapterProps.class}></div> <div bind:this={container} class={$$props.class}></div>
+53 -1
View File
@@ -1 +1,53 @@
export { settingsPopup } from "@/seqta/utils/settingsPopup"; type SettingsPopupCallback = () => void;
/**
* This is a singleton that triggers an update when the settings popup is closed.
* This is used to close the colour picker.
* Usage:
* settingsPopup.addListener(() => {
* console.log('Settings popup closed');
* });
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
/**
* Registers a callback function to be invoked when the settings popup is closed.
*
* @param {SettingsPopupCallback} callback The function to call when the settings popup closes.
* This callback takes no arguments and returns void.
*/
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
/**
* Unregisters a previously added callback function.
* After calling this method, the provided callback will no longer be invoked when the settings popup closes.
*
* @param {SettingsPopupCallback} callback The callback function to remove from the listeners.
*/
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
/**
* Invokes all registered listener callbacks.
* This method should be called when the settings popup is closed to notify all subscribed components or services.
*/
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+25 -38
View File
@@ -6,7 +6,7 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { standalone as StandaloneStore } from "../utils/standalone.svelte"; import { standalone as StandaloneStore } from "../utils/standalone.svelte";
import { onMount, onDestroy } from "svelte"; import { onMount } from "svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"; import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup";
@@ -14,11 +14,11 @@
import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup"; import { OpenWhatsNewPopup } from "@/seqta/utils/Openers/OpenWhatsNewPopup";
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import type { Component } from "svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import FontPickerModal from "../components/FontPickerModal.svelte"; import FontPickerModal from "../components/FontPickerModal.svelte";
import CloudPanel from "../components/CloudPanel.svelte"; import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import { settingsPopup } from "@/seqta/utils/settingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
import { import {
checkGithubReleaseUpdate, checkGithubReleaseUpdate,
dismissNightlyUpdate, dismissNightlyUpdate,
@@ -64,12 +64,7 @@
}, 10000); }, 10000);
}; };
let ColourPickerComponent = $state<Component | null>(null); const openColourPicker = () => {
const openColourPicker = async () => {
if (!ColourPickerComponent) {
ColourPickerComponent = (await import("../components/ColourPicker.svelte")).default;
}
showColourPicker = true; showColourPicker = true;
}; };
@@ -113,14 +108,12 @@
showDisclaimerModal = true; showDisclaimerModal = true;
}; };
const closePopupsOnSettingsClose = () => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
};
onMount(() => { onMount(() => {
settingsPopup.addListener(closePopupsOnSettingsClose); settingsPopup.addListener(() => {
showColourPicker = false;
showFontPicker = false;
showCloudPanel = false;
});
if (standalone) { if (standalone) {
StandaloneStore.setStandalone(true); StandaloneStore.setStandalone(true);
@@ -132,10 +125,6 @@
}); });
} }
}); });
onDestroy(() => {
settingsPopup.removeListener(closePopupsOnSettingsClose);
});
</script> </script>
<div <div
@@ -144,10 +133,10 @@
: ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip" : ''} {standalone ? 'h-[600px]' : 'h-full rounded-xl'} overflow-clip"
> >
<div <div
class="flex relative flex-col gap-2 h-full min-h-0 overflow-hidden bg-white dark:bg-zinc-800 dark:text-white" class="flex relative flex-col gap-2 h-full overflow-clip bg-white dark:bg-zinc-800 dark:text-white"
> >
<div <div
class="grid shrink-0 place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40" class="grid place-items-center border-b border-b-zinc-200/40 dark:border-b-zinc-700/40"
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -354,24 +343,22 @@
{/if} {/if}
</div> </div>
<div class="flex-1 min-h-0 overflow-hidden"> <TabbedContainer
<TabbedContainer bind:activeTab={settingsActiveTab}
bind:activeTab={settingsActiveTab} tabs={[
tabs={[ {
{ title: "Settings",
title: "Settings", Content: Settings,
Content: Settings, props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel },
props: { showColourPicker: openColourPicker, showFontPicker: openFontPicker, showDisclaimer, showCloudPanel: openCloudPanel }, },
}, { title: "Shortcuts", Content: Shortcuts },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Themes", Content: Theme },
{ title: "Themes", Content: Theme }, ]}
]} />
/>
</div>
</div> </div>
{#if showColourPicker && ColourPickerComponent} {#if showColourPicker}
<ColourPickerComponent <ColourPicker
hidePicker={() => { hidePicker={() => {
showColourPicker = false; showColourPicker = false;
}} }}
+3 -4
View File
@@ -19,7 +19,6 @@
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" import { getStoredOverride, setApiBase } from "@/seqta/utils/DevApiBase"
import { onMount } from "svelte"
let devApiBaseInput = $state<string>(getStoredOverride() ?? "") let devApiBaseInput = $state<string>(getStoredOverride() ?? "")
let devApiBaseActive = $state<string | null>(getStoredOverride()) let devApiBaseActive = $state<string | null>(getStoredOverride())
@@ -129,9 +128,9 @@
await browser.storage.local.set({ [storageKey]: currentSettings }); await browser.storage.local.set({ [storageKey]: currentSettings });
} }
onMount(() => { $effect(() => {
void loadPluginSettings(); loadPluginSettings();
}); })
const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{ const { showColourPicker, showFontPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void; showColourPicker: () => void;
+6 -5
View File
@@ -23,10 +23,7 @@
const themeManager = ThemeManager.getInstance(); const themeManager = ThemeManager.getInstance();
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn); let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
$effect(() => { cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
const unsub = cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
return unsub;
});
// State variables // State variables
let searchTerm = $state(''); let searchTerm = $state('');
@@ -89,11 +86,13 @@
} }
const toggleFavorite = async (theme: Theme) => { const toggleFavorite = async (theme: Theme) => {
if (!cloudLoggedIn) return; const token = await cloudAuth.getStoredToken();
if (!token) return;
const isFavorite = !theme.is_favorited; const isFavorite = !theme.is_favorited;
const result = (await browser.runtime.sendMessage({ const result = (await browser.runtime.sendMessage({
type: 'cloudFavorite', type: 'cloudFavorite',
themeId: theme.id, themeId: theme.id,
token,
action: isFavorite ? 'favorite' : 'unfavorite', action: isFavorite ? 'favorite' : 'unfavorite',
})) as { success?: boolean }; })) as { success?: boolean };
if (result?.success) { if (result?.success) {
@@ -120,12 +119,14 @@
error = null; error = null;
} }
try { try {
const token = await cloudAuth.getStoredToken();
const data = await sendMessageWithTimeout<{ const data = await sendMessageWithTimeout<{
success?: boolean; success?: boolean;
data?: { themes: unknown[] }; data?: { themes: unknown[] };
error?: string; error?: string;
}>({ }>({
type: 'fetchThemes', type: 'fetchThemes',
token: token ?? undefined,
}); });
if (!data?.success || !Array.isArray(data?.data?.themes)) { if (!data?.success || !Array.isArray(data?.data?.themes)) {
throw new Error(data?.error || 'Failed to fetch themes'); throw new Error(data?.error || 'Failed to fetch themes');
+6 -14
View File
@@ -509,13 +509,7 @@ function deepFunctionCheck(obj, path = "") {
} }
} }
function isTrustedMessage(event) {
return event.source === window && event.origin === window.location.origin;
}
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (!isTrustedMessage(event)) return;
if (event.data.type === "reactFiberRequest") { if (event.data.type === "reactFiberRequest") {
const { selector, action, payload, debug, messageId } = event.data; const { selector, action, payload, debug, messageId } = event.data;
const fiberInstance = ReactFiber.find(selector, { const fiberInstance = ReactFiber.find(selector, {
@@ -528,14 +522,12 @@ window.addEventListener("message", (event) => {
response = fiberInstance.getState(payload.key); response = fiberInstance.getState(payload.key);
break; break;
case "setState": case "setState":
if ( // Handle both function and object updates
payload.updateObject && if (payload.updateFn) {
typeof payload.updateObject === "object" && const updateFn = new Function('return ' + payload.updateFn)();
!Array.isArray(payload.updateObject) fiberInstance.setState(updateFn);
) {
fiberInstance.setState(payload.updateObject);
} else { } else {
console.warn("[pageState] setState rejected: only plain objects are allowed"); fiberInstance.setState(payload.updateObject);
} }
response = {}; response = {};
break; break;
@@ -588,7 +580,7 @@ window.addEventListener("message", (event) => {
response, response,
messageId, messageId,
}, },
window.location.origin, "*",
); );
} else if (event.data.type === "triggerKeyboardEvent") { } else if (event.data.type === "triggerKeyboardEvent") {
// Handle keyboard event triggering from content script // Handle keyboard event triggering from content script
@@ -17,7 +17,7 @@ import {
processAssessments, processAssessments,
type WeightingEntry, type WeightingEntry,
} from "./utils.ts"; } from "./utils.ts";
import { injectRubricCopyButtons, teardownRubricCopyButtons } from "./rubricCopy.ts"; import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage { interface weightingsStorage {
weightings: Record<string, WeightingEntry>; weightings: Record<string, WeightingEntry>;
@@ -41,8 +41,6 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
const instance = new AssessmentsAveragePluginClass(); const instance = new AssessmentsAveragePluginClass();
let overrideListenerController: AbortController | null = null; let overrideListenerController: AbortController | null = null;
let wrapperColourObserver: MutationObserver | null = null;
let wrapperColourObserverTimeout: ReturnType<typeof setTimeout> | null = null;
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = { const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
id: "assessments-average", id: "assessments-average",
@@ -56,9 +54,7 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
await initStorage(api); await initStorage(api);
clearStuck(api); clearStuck(api);
const { unregister: unregisterWrapperMount } = api.seqta.onMount( api.seqta.onMount(".assessmentsWrapper", async () => {
".assessmentsWrapper",
async () => {
await waitForElm( await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
true, true,
@@ -92,43 +88,17 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
void parseAssessments(api); void parseAssessments(api);
const wrapper = document.querySelector(".assessmentsWrapper"); const wrapper = document.querySelector(".assessmentsWrapper");
if (wrapper) { if (wrapper) {
wrapperColourObserver?.disconnect(); const observer = new MutationObserver(() => {
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
}
wrapperColourObserver = new MutationObserver(() => {
applySubjectColourToOverallResult(); applySubjectColourToOverallResult();
}); });
wrapperColourObserver.observe(wrapper, { childList: true, subtree: true }); observer.observe(wrapper, { childList: true, subtree: true });
wrapperColourObserverTimeout = setTimeout(() => { setTimeout(() => observer.disconnect(), 10000);
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
wrapperColourObserverTimeout = null;
}, 10000);
} }
}, });
); api.seqta.onMount("[class*='SelectedAssessment__']", () => {
const { unregister: unregisterSelectedMount } = api.seqta.onMount(
"[class*='SelectedAssessment__']",
() => {
injectWeightingsTab(api); injectWeightingsTab(api);
injectRubricCopyButtons(); injectRubricCopyButtons();
}, });
);
return () => {
overrideListenerController?.abort();
overrideListenerController = null;
wrapperColourObserver?.disconnect();
wrapperColourObserver = null;
if (wrapperColourObserverTimeout) {
clearTimeout(wrapperColourObserverTimeout);
wrapperColourObserverTimeout = null;
}
teardownRubricCopyButtons();
unregisterWrapperMount();
unregisterSelectedMount();
};
}, },
}; };
+44 -156
View File
@@ -25,91 +25,6 @@ export interface WeightingEntry {
export type WeightingsMap = Record<string, WeightingEntry>; export type WeightingsMap = Record<string, WeightingEntry>;
/** Primary storage key for weightings / overrides. */
export function assessmentIdKey(mark: { id: string | number }): string {
return String(mark.id);
}
/** Composite lookup key when the same title appears in multiple metaclasses. */
export function assessmentTitleLookupKey(mark: {
metaclassID?: string | number;
title?: string;
}): string | null {
const title = mark.title?.trim();
if (!title) return null;
const metaclassID = mark.metaclassID;
if (metaclassID != null && metaclassID !== "") {
return `${metaclassID}:${title}`;
}
return title;
}
function registerAssessmentLookup(api: any, mark: any) {
const assessmentID = assessmentIdKey(mark);
const next: Record<string, string> = {
...api.storage.assessments,
[assessmentID]: assessmentID,
};
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey) next[compositeKey] = assessmentID;
api.storage.assessments = next;
}
type MarkLike = {
id: string | number;
title?: string;
metaclassID?: string | number;
};
function collectMarksFromFiberState(state: Record<string, unknown>): MarkLike[] {
return [
...(Array.isArray(state.marks) ? state.marks : []),
...(Array.isArray(state.upcoming) ? state.upcoming : []),
...(Array.isArray(state.pending) ? state.pending : []),
] as MarkLike[];
}
async function resolveAssessmentId(
api: any,
title: string,
marks?: MarkLike[],
): Promise<string | undefined> {
const assessments = (api.storage.assessments ?? {}) as Record<string, string>;
let resolvedMarks = marks;
if (!resolvedMarks) {
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
resolvedMarks = collectMarksFromFiberState(state);
} catch {
resolvedMarks = [];
}
}
const matching = resolvedMarks.filter((mark) => mark.title?.trim() === title);
if (matching.length === 1) {
return assessmentIdKey(matching[0]);
}
for (const mark of matching) {
const compositeKey = assessmentTitleLookupKey(mark);
if (compositeKey && assessments[compositeKey]) {
return assessments[compositeKey];
}
}
if (assessments[title]) return assessments[title];
const suffix = `:${title}`;
for (const [key, id] of Object.entries(assessments)) {
if (key.endsWith(suffix)) return id;
}
return undefined;
}
export function computeFingerprint(mark: any): string { export function computeFingerprint(mark: any): string {
const score = const score =
mark?.results?.percentage ?? mark?.results?.score ?? null; mark?.results?.percentage ?? mark?.results?.score ?? null;
@@ -349,7 +264,6 @@ function createWeightLabel(
weighting: string | undefined, weighting: string | undefined,
api: any, api: any,
refreshing = false, refreshing = false,
assessmentID?: string,
) { ) {
let statsContainer = assessmentItem.querySelector( let statsContainer = assessmentItem.querySelector(
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
@@ -375,8 +289,10 @@ function createWeightLabel(
? "space-between" ? "space-between"
: "flex-end"; : "flex-end";
const resolvedAssessmentId = const title = assessmentItem
assessmentID ?? assessmentItem.dataset.betterseqtaAssessmentId; .querySelector(`[class*='AssessmentItem__title___']`)
?.textContent?.trim();
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
const existingLabel = statsContainer.querySelector( const existingLabel = statsContainer.querySelector(
".betterseqta-weight-label", ".betterseqta-weight-label",
@@ -386,7 +302,7 @@ function createWeightLabel(
updateWeightLabelContent( updateWeightLabelContent(
existingLabel, existingLabel,
weighting, weighting,
resolvedAssessmentId, assessmentID,
api, api,
refreshing, refreshing,
); );
@@ -424,7 +340,7 @@ function createWeightLabel(
updateWeightLabelContent( updateWeightLabelContent(
weightLabel, weightLabel,
weighting, weighting,
resolvedAssessmentId, assessmentID,
api, api,
refreshing, refreshing,
); );
@@ -436,24 +352,14 @@ export const isFirefox =
!navigator.userAgent.toLowerCase().includes("seamonkey") && !navigator.userAgent.toLowerCase().includes("seamonkey") &&
!navigator.userAgent.toLowerCase().includes("waterfox"); !navigator.userAgent.toLowerCase().includes("waterfox");
function trustedPageOrigin(): string {
return window.location.origin;
}
function escJsSingleQuoted(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
}
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> { async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
const isBlobUrl = url.startsWith("blob:"); const isBlobUrl = url.startsWith("blob:");
const pageOrigin = trustedPageOrigin();
if (isBlobUrl || isFirefox) { if (isBlobUrl || isFirefox) {
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 = escJsSingleQuoted(url); const escapedUrl = url.replace(/'/g, "\\'");
const escapedOrigin = escJsSingleQuoted(pageOrigin);
script.textContent = ` script.textContent = `
(function() { (function() {
@@ -469,20 +375,19 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
type: '${requestId}', type: '${requestId}',
success: true, success: true,
data: Array.from(new Uint8Array(arrayBuffer)) data: Array.from(new Uint8Array(arrayBuffer))
}, '${escapedOrigin}'); }, '*');
}) })
.catch(error => { .catch(error => {
window.postMessage({ window.postMessage({
type: '${requestId}', type: '${requestId}',
success: false, success: false,
error: error.message || String(error) error: error.message || String(error)
}, '${escapedOrigin}'); }, '*');
}); });
})(); })();
`; `;
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) { if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler); window.removeEventListener("message", messageHandler);
if (script.parentNode) { if (script.parentNode) {
@@ -544,22 +449,23 @@ export async function extractPDFText(url: string): Promise<string> {
if (isFirefox) { if (isFirefox) {
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);
const pageOrigin = trustedPageOrigin();
const escapedOrigin = escJsSingleQuoted(pageOrigin);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
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 = escJsSingleQuoted(url); const escapedUrl = url
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
script.textContent = ` script.textContent = `
(function() { (function() {
const requestId = '${requestId}'; const requestId = '${requestId}';
const pageOrigin = '${escapedOrigin}';
const url = '${escapedUrl}'; const url = '${escapedUrl}';
const pdfLibSrc = '${pdfLibInj}'; const pdfLibSrc = '${pdfLibInj}';
const pdfWorkerSrc = '${pdfWorkerInj}'; const pdfWorkerSrc = '${pdfWorkerInj}';
@@ -579,7 +485,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: false, success: false,
error: 'Failed to load pdfjs library' error: 'Failed to load pdfjs library'
}, pageOrigin); }, '*');
}; };
document.head.appendChild(pdfjsScript); document.head.appendChild(pdfjsScript);
@@ -600,7 +506,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: false, success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, pageOrigin); }, '*');
return; return;
} }
@@ -636,21 +542,21 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: true, success: true,
text: text text: text
}, pageOrigin); }, '*');
}) })
.catch(error => { .catch(error => {
window.postMessage({ window.postMessage({
type: requestId, type: requestId,
success: false, success: false,
error: 'PDF parsing error: ' + (error.message || String(error)) error: 'PDF parsing error: ' + (error.message || String(error))
}, pageOrigin); }, '*');
}); });
} catch (error) { } catch (error) {
window.postMessage({ window.postMessage({
type: requestId, type: requestId,
success: false, success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error)) error: 'ArrayBuffer error: ' + (error.message || String(error))
}, pageOrigin); }, '*');
} }
}; };
@@ -659,7 +565,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: false, success: false,
error: 'Network error fetching PDF' error: 'Network error fetching PDF'
}, pageOrigin); }, '*');
}; };
xhr.ontimeout = function() { xhr.ontimeout = function() {
@@ -667,7 +573,7 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: false, success: false,
error: 'Timeout fetching PDF' error: 'Timeout fetching PDF'
}, pageOrigin); }, '*');
}; };
xhr.timeout = 30000; xhr.timeout = 30000;
@@ -677,14 +583,13 @@ export async function extractPDFText(url: string): Promise<string> {
type: requestId, type: requestId,
success: false, success: false,
error: 'Setup error: ' + (error.message || String(error)) error: 'Setup error: ' + (error.message || String(error))
}, pageOrigin); }, '*');
} }
} }
})(); })();
`; `;
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
if (event.origin !== pageOrigin || event.source !== window) return;
if (event.data?.type === requestId) { if (event.data?.type === requestId) {
window.removeEventListener("message", messageHandler); window.removeEventListener("message", messageHandler);
if (script.parentNode) { if (script.parentNode) {
@@ -741,8 +646,9 @@ export async function extractPDFText(url: string): Promise<string> {
} }
async function handleWeightings(mark: any, api: any) { async function handleWeightings(mark: any, api: any) {
const assessmentID = assessmentIdKey(mark); const assessmentID = mark.id;
const metaclassID = mark.metaclassID; const metaclassID = mark.metaclassID;
const title = mark.title;
const fingerprint = computeFingerprint(mark); const fingerprint = computeFingerprint(mark);
const existing = api.storage.weightings[assessmentID] as const existing = api.storage.weightings[assessmentID] as
@@ -781,7 +687,10 @@ async function handleWeightings(mark: any, api: any) {
[assessmentID]: placeholder, [assessmentID]: placeholder,
}; };
registerAssessmentLookup(api, mark); api.storage.assessments = {
...api.storage.assessments,
[title.trim()]: assessmentID,
};
// Surface the refreshing indicator on the affected row immediately, // Surface the refreshing indicator on the affected row immediately,
// without waiting for the PDF fetch to finish. // without waiting for the PDF fetch to finish.
@@ -904,16 +813,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
let hasRefreshingWeighting = false; let hasRefreshingWeighting = false;
let count = 0; let count = 0;
let fiberMarks: MarkLike[] = [];
try {
const state = await ReactFiber.find(
"[class*='AssessmentList__items___']",
).getState();
fiberMarks = collectMarksFromFiberState(state);
} catch {
fiberMarks = [];
}
for (const assessmentItem of assessmentItems) { for (const assessmentItem of assessmentItems) {
const titleEl = assessmentItem.querySelector( const titleEl = assessmentItem.querySelector(
`[class*='AssessmentItem__title___']`, `[class*='AssessmentItem__title___']`,
@@ -923,11 +822,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
const title = titleEl.textContent?.trim(); const title = titleEl.textContent?.trim();
if (!title) continue; if (!title) continue;
const assessmentID = await resolveAssessmentId(api, title, fiberMarks); const assessmentID = api.storage.assessments?.[title];
if (assessmentID) {
(assessmentItem as HTMLElement).dataset.betterseqtaAssessmentId =
assessmentID;
}
const entry = assessmentID const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined) ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
: undefined; : undefined;
@@ -938,7 +833,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
const weighting = override ?? autoWeighting; const weighting = override ?? autoWeighting;
const refreshing = !override && Boolean(entry?.refreshing); const refreshing = !override && Boolean(entry?.refreshing);
createWeightLabel(assessmentItem, weighting, api, refreshing, assessmentID); createWeightLabel(assessmentItem, weighting, api, refreshing);
const gradeElement = assessmentItem.querySelector( const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`, `[class*='Thermoscore__text___']`,
@@ -1040,17 +935,12 @@ function resolveTabSetClasses(): Record<string, string> {
return resolved; return resolved;
} }
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) { function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
const selectedItem = document.querySelector( const titleEl = document.querySelector(
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", "[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
) as HTMLElement | null;
const titleEl = selectedItem?.querySelector(
"[class*='AssessmentItem__title___']",
); );
const title = titleEl?.textContent?.trim(); const title = titleEl?.textContent?.trim();
const assessmentID = const assessmentID = title ? api.storage.assessments?.[title] : undefined;
selectedItem?.dataset.betterseqtaAssessmentId ??
(title ? await resolveAssessmentId(api, title) : undefined);
const entry = assessmentID const entry = assessmentID
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined) ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
@@ -1203,7 +1093,7 @@ export function injectWeightingsTab(api: any) {
container.appendChild(newSheet); container.appendChild(newSheet);
newTab.addEventListener("click", () => { newTab.addEventListener("click", () => {
void buildWeightingsTabContent(api, newSheet); buildWeightingsTabContent(api, newSheet);
}); });
const allTabs = Array.from(tabList.querySelectorAll("li")); const allTabs = Array.from(tabList.querySelectorAll("li"));
@@ -1217,22 +1107,20 @@ export function injectWeightingsTab(api: any) {
t.className.includes("TabSet__selected___"), t.className.includes("TabSet__selected___"),
); );
if (i === currentIndex) return; if (i === currentIndex) return;
const goingRight = currentIndex < 0 ? true : i > currentIndex; const goingRight = i > currentIndex;
allTabs.forEach((t) => { allTabs.forEach((t) => {
t.className = ""; t.className = "";
t.setAttribute("aria-selected", "false"); t.setAttribute("aria-selected", "false");
}); });
if (currentIndex >= 0) { allSheets[currentIndex].className = [
allSheets[currentIndex].className = [ cls["TabSet__tabsheet___"],
cls["TabSet__tabsheet___"], cls["TabSet__hidden___"],
cls["TabSet__hidden___"], goingRight
goingRight ? cls["TabSet__disappearToLeft___"]
? cls["TabSet__disappearToLeft___"] : cls["TabSet__disappearToRight___"],
: cls["TabSet__disappearToRight___"], ].join(" ");
].join(" ");
}
allSheets[i].className = [ allSheets[i].className = [
cls["TabSet__tabsheet___"], cls["TabSet__tabsheet___"],
@@ -29,9 +29,6 @@ async function fetchJSON(url: string, body: any) {
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json(); return res.json();
} }
@@ -167,7 +164,7 @@ async function getLearnAssessmentsData(studentId: number) {
} }
export async function getAssessmentsData() { export async function getAssessmentsData() {
if (settingsState.hideSensitiveContent) { if (settingsState.mockNotices) {
return getMockAssessmentsData(); return getMockAssessmentsData();
} }
@@ -38,9 +38,6 @@ async function fetchJSON(url: string, body: unknown) {
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json(); return res.json();
} }
@@ -1,7 +1,7 @@
import type { Plugin } from "../../core/types"; import type { Plugin } from "../../core/types";
import { waitForElm } from "@/seqta/utils/waitForElm"; import { waitForElm } from "@/seqta/utils/waitForElm";
import { getAssessmentsData } from "./api"; import { getAssessmentsData } from "./api";
import { renderErrorState, renderGrid, renderSkeletonLoader, teardownOverviewUi } from "./ui"; import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
import styles from "./styles.css?inline"; import styles from "./styles.css?inline";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage"; import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
@@ -61,14 +61,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
const gridItem = document.createElement("li"); const gridItem = document.createElement("li");
gridItem.className = "item"; gridItem.className = "item";
gridItem.classList.add(OVERVIEW_MENU_CLASS); gridItem.classList.add(OVERVIEW_MENU_CLASS);
gridItem.dataset.betterseqta = "true";
const label = document.createElement("label"); const label = document.createElement("label");
label.textContent = "Overview"; label.textContent = "Overview";
gridItem.appendChild(label); gridItem.appendChild(label);
menu.insertBefore(gridItem, menu.firstChild); menu.insertBefore(gridItem, menu.firstChild);
let loadRequestId = 0;
const menuObserver = new MutationObserver(() => { const menuObserver = new MutationObserver(() => {
ensureOverviewMenuPosition(menu, gridItem); ensureOverviewMenuPosition(menu, gridItem);
}); });
@@ -80,24 +77,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
const clickHandler = (e: Event) => { const clickHandler = (e: Event) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
void loadGridView(); void loadGridView();
}; };
gridItem.addEventListener("click", clickHandler, true); gridItem.addEventListener("click", clickHandler);
const popstateHandler = () => {
if (isOverviewRoute()) {
void loadGridView();
} else {
loadRequestId += 1;
teardownOverviewUi();
}
};
window.addEventListener("popstate", popstateHandler);
async function loadGridView() { async function loadGridView() {
const requestId = ++loadRequestId;
await delay(1); await delay(1);
if (isSeqtaEngageExperience()) { if (isSeqtaEngageExperience()) {
@@ -114,7 +98,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
} }
const main = document.getElementById("main"); const main = document.getElementById("main");
if (!main || requestId !== loadRequestId) return; if (!main) return;
document document
.querySelectorAll('[data-key="assessments"] .item') .querySelectorAll('[data-key="assessments"] .item')
@@ -126,22 +110,17 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
.querySelector('[data-key="assessments"]') .querySelector('[data-key="assessments"]')
?.classList.add("active"); ?.classList.add("active");
main.innerHTML = main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
'<div id="grid-view-container" class="bsplus-overview-host"></div>';
const container = document.getElementById( const container = document.getElementById(
"grid-view-container", "grid-view-container",
) as HTMLElement; ) as HTMLElement;
if (requestId !== loadRequestId) return;
renderSkeletonLoader(container); renderSkeletonLoader(container);
try { try {
const data = await getAssessmentsData(); const data = await getAssessmentsData();
if (requestId !== loadRequestId) return;
renderGrid(container, data); renderGrid(container, data);
} catch (err) { } catch (err) {
if (requestId !== loadRequestId) return;
console.error("Failed to load assessments:", err); console.error("Failed to load assessments:", err);
renderErrorState( renderErrorState(
container, container,
@@ -151,11 +130,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
} }
return () => { return () => {
loadRequestId += 1;
window.removeEventListener("popstate", popstateHandler);
menuObserver.disconnect(); menuObserver.disconnect();
gridItem.removeEventListener("click", clickHandler, true); gridItem.removeEventListener("click", clickHandler);
teardownOverviewUi();
gridItem.remove(); gridItem.remove();
}; };
}, },
@@ -62,7 +62,7 @@ export function activeSubjectsFromEngageChild(child: {
const seen = new Set<string>(); const seen = new Set<string>();
for (const term of child.terms ?? []) { for (const term of child.terms ?? []) {
if (!isActiveTermFlag(term.active)) continue; if (term.active !== 1) continue;
for (const raw of term.subjects ?? []) { for (const raw of term.subjects ?? []) {
const subject = normalizeOverviewSubject(raw); const subject = normalizeOverviewSubject(raw);
if (!subject) continue; if (!subject) continue;
@@ -151,14 +151,7 @@ export function determineStatus(item: any): string {
} }
const completedKey = "betterseqta-completed-assessments"; const completedKey = "betterseqta-completed-assessments";
let completed: unknown[] = []; const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
try {
const raw = localStorage.getItem(completedKey);
const parsed = raw ? JSON.parse(raw) : [];
completed = Array.isArray(parsed) ? parsed : [];
} catch {
completed = [];
}
if (completed.includes(item.id)) { if (completed.includes(item.id)) {
return "MARKS_RELEASED"; return "MARKS_RELEASED";
} }
+3 -11
View File
@@ -74,10 +74,7 @@ function ensureGestureStart(handler: () => void): () => void {
async function startPlayback(volume: number): Promise<void> { async function startPlayback(volume: number): Promise<void> {
const blob = await loadAudioBlob(); const blob = await loadAudioBlob();
if (!blob) { if (!blob) return;
stopAndCleanupAudio();
return;
}
stopAndCleanupAudio(); stopAndCleanupAudio();
@@ -126,7 +123,7 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
} }
}); });
// Note: Stop button dispatches betterseqta-background-music-stop on remove // Note: Stop button/event removed by user; no stop handling needed
// Start if we have audio and autoplay is enabled // Start if we have audio and autoplay is enabled
const tryStart = async () => { const tryStart = async () => {
@@ -163,21 +160,16 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
}; };
document.addEventListener("visibilitychange", visHandler); document.addEventListener("visibilitychange", visHandler);
// Allow uploads to trigger refresh; stop event clears playback on remove // Allow uploads to trigger refresh
const uploadedHandler = () => { const uploadedHandler = () => {
const vol = (api.settings as any).volume ?? 0.5; const vol = (api.settings as any).volume ?? 0.5;
startPlayback(vol); startPlayback(vol);
}; };
const stopHandler = () => {
stopAndCleanupAudio();
};
window.addEventListener("betterseqta-background-music-updated", uploadedHandler); window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
window.addEventListener("betterseqta-background-music-stop", stopHandler);
return () => { return () => {
document.removeEventListener("visibilitychange", visHandler); document.removeEventListener("visibilitychange", visHandler);
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler); window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) { if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
(window as any).__betterseqta_bg_music_cancel__(); (window as any).__betterseqta_bg_music_cancel__();
(window as any).__betterseqta_bg_music_cancel__ = undefined; (window as any).__betterseqta_bg_music_cancel__ = undefined;
@@ -255,9 +255,9 @@ const watchNavigator = (navigator: Element, onChange: () => void) => {
return observer; return observer;
}; };
const handleSlidePane = (pane: Element): (() => void) => { const handleSlidePane = (pane: Element) => {
const navigator = pane.querySelector(".navigator"); const navigator = pane.querySelector(".navigator");
if (!navigator) return () => {}; if (!navigator) return;
requestAnimationFrame(() => scrollSelectedIntoView(navigator)); requestAnimationFrame(() => scrollSelectedIntoView(navigator));
setTimeout(() => scrollSelectedIntoView(navigator), 50); setTimeout(() => scrollSelectedIntoView(navigator), 50);
@@ -272,22 +272,17 @@ const handleSlidePane = (pane: Element): (() => void) => {
childList: true, childList: true,
}); });
const paneCleanup = new MutationObserver((muts) => { const cleanup = new MutationObserver((muts) => {
muts.forEach((m) => { muts.forEach((m) => {
m.removedNodes.forEach((n) => { m.removedNodes.forEach((n) => {
if (n === pane) { if (n === pane) {
observer.disconnect(); observer.disconnect();
paneCleanup.disconnect(); cleanup.disconnect();
} }
}); });
}); });
}); });
paneCleanup.observe(document.body, { childList: true }); cleanup.observe(document.body, { childList: true });
return () => {
observer.disconnect();
paneCleanup.disconnect();
};
}; };
const enhancedNavigationPlugin: Plugin<typeof settings> = { const enhancedNavigationPlugin: Plugin<typeof settings> = {
@@ -306,11 +301,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
window.addEventListener("resize", positionArrows); window.addEventListener("resize", positionArrows);
window.addEventListener("scroll", positionArrows, true); window.addEventListener("scroll", positionArrows, true);
const navObservers: MutationObserver[] = []; api.seqta.onMount(".course", async (element) => {
const courseObservers: MutationObserver[] = [];
const slidePaneCleanups: Array<() => void> = [];
const courseMount = api.seqta.onMount(".course", async (element) => {
const course = element as HTMLElement; const course = element as HTMLElement;
let navObserver: MutationObserver | null = null; let navObserver: MutationObserver | null = null;
@@ -327,7 +318,6 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
} }
ensureArrows(course); ensureArrows(course);
}); });
navObservers.push(navObserver);
return true; return true;
}; };
@@ -335,7 +325,6 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
const courseObserver = new MutationObserver(() => { const courseObserver = new MutationObserver(() => {
if (setup()) courseObserver.disconnect(); if (setup()) courseObserver.disconnect();
}); });
courseObservers.push(courseObserver);
courseObserver.observe(course, { childList: true, subtree: true }); courseObserver.observe(course, { childList: true, subtree: true });
} }
}); });
@@ -345,21 +334,13 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
m.addedNodes.forEach((n) => { m.addedNodes.forEach((n) => {
if (n.nodeType !== 1) return; if (n.nodeType !== 1) return;
const el = n as Element; const el = n as Element;
if (el.classList?.contains("uiSlidePane")) { if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
slidePaneCleanups.push(handleSlidePane(el));
}
}); });
}); });
}); });
bodyObserver.observe(document.body, { childList: true }); bodyObserver.observe(document.body, { childList: true });
return () => { return () => {
window.removeEventListener("resize", positionArrows);
window.removeEventListener("scroll", positionArrows, true);
courseMount.unregister();
navObservers.forEach((observer) => observer.disconnect());
courseObservers.forEach((observer) => observer.disconnect());
slidePaneCleanups.forEach((cleanup) => cleanup());
bodyObserver.disconnect(); bodyObserver.disconnect();
document.getElementById(ARROW_CONTAINER_ID)?.remove(); document.getElementById(ARROW_CONTAINER_ID)?.remove();
document.getElementById(STYLE_ID)?.remove(); document.getElementById(STYLE_ID)?.remove();
@@ -5,7 +5,7 @@
import { circOut, quintOut } from 'svelte/easing'; import { circOut, quintOut } from 'svelte/easing';
import { type StaticCommandItem } from '../core/commands'; import { type StaticCommandItem } from '../core/commands';
import type { CombinedResult } from '../core/types'; import type { CombinedResult } from '../core/types';
import { createSearchIndexes, applyDynamicIndexDelta, performSearch as doSearch, type DynamicItemsUpdatedDetail } from '../search/searchUtils'; import { createSearchIndexes, performSearch as doSearch } from '../search/searchUtils';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import Calculator from './Calculator.svelte'; import Calculator from './Calculator.svelte';
import { actionMap } from '../indexing/actions'; import { actionMap } from '../indexing/actions';
@@ -129,31 +129,7 @@
window.addEventListener('indexing-progress', progressHandler as EventListener); window.addEventListener('indexing-progress', progressHandler as EventListener);
const itemsUpdatedHandler = (event: Event) => { const itemsUpdatedHandler = () => {
const detail = (event as CustomEvent<DynamicItemsUpdatedDetail>).detail;
if (
detail?.vectorUpdate &&
!detail.changedItems?.length &&
!detail.removedIds?.length
) {
performSearch();
return;
}
if (detail?.incremental && !detail.fullRebuild) {
const updatedFuse = applyDynamicIndexDelta(
dynamicContentFuse,
dynamicIdToItemMap,
detail,
);
if (updatedFuse) {
dynamicContentFuse = updatedFuse;
performSearch();
return;
}
}
setupSearchIndexes(); setupSearchIndexes();
performSearch(); performSearch();
}; };
@@ -199,35 +175,29 @@
const term = searchTerm.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
const requestId = ++searchRequestId; const requestId = ++searchRequestId;
try { if (commandsFuse && dynamicContentFuse) {
if (commandsFuse && dynamicContentFuse) { const results = await doSearch(
const results = await doSearch( term,
term, commandsFuse,
commandsFuse, commandIdToItemMap,
commandIdToItemMap, dynamicContentFuse,
dynamicContentFuse, dynamicIdToItemMap,
dynamicIdToItemMap, true, // sortByRecent
true, // sortByRecent );
);
// Drop the result if the user has typed since this search started, or // Drop the result if the user has typed since this search started, or
// if the current term no longer matches what we searched for. This // if the current term no longer matches what we searched for. This
// keeps the visible list anchored to the latest query. // keeps the visible list anchored to the latest query.
if (requestId !== searchRequestId) return; if (requestId !== searchRequestId) return;
if (searchTerm.trim().toLowerCase() !== term) return; if (searchTerm.trim().toLowerCase() !== term) return;
combinedResults = results; combinedResults = results;
} else { } else {
if (requestId !== searchRequestId) return; if (requestId !== searchRequestId) return;
combinedResults = []; combinedResults = [];
}
} finally {
// Only clear loading for the latest in-flight search — stale async
// passes must not leave the spinner stuck after fast typing.
if (requestId === searchRequestId) {
isLoading = false;
}
} }
isLoading = false;
}; };
// Optimized debounce: shorter delay for better responsiveness // Optimized debounce: shorter delay for better responsiveness
@@ -214,7 +214,7 @@ const staticCommands: StaticCommandItem[] = [
code: 'KeyM', code: 'KeyM',
keyCode: 77, keyCode: 77,
altKey: true altKey: true
}, location.origin); }, "*");
}, },
keywords: ["compose", "message", "dm", "direct message", "new message"], keywords: ["compose", "message", "dm", "direct message", "new message"],
priority: 3, priority: 3,
@@ -286,10 +286,10 @@ const globalSearchPlugin: Plugin<typeof settings> = {
const title = document.querySelector("#title"); const title = document.querySelector("#title");
if (title) { if (title) {
void mountSearchBar(title, api, appRef); mountSearchBar(title, api, appRef);
} else { } else {
const titleElement = await waitForElm("#title", true, 100, 60); const titleElement = await waitForElm("#title", true, 100, 60);
void mountSearchBar(titleElement, api, appRef); mountSearchBar(titleElement, api, appRef);
} }
return () => { return () => {
@@ -1,10 +1,11 @@
import renderSvelte from "@/interface/main";
import SearchBar from "../components/SearchBar.svelte"; import SearchBar from "../components/SearchBar.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager"; import { VectorWorkerManager } from "../indexing/worker/vectorWorkerManager";
import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils"; import { formatHotkeyForDisplay, isValidHotkey } from "../utils/hotkeyUtils";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
export async function mountSearchBar( export function mountSearchBar(
titleElement: Element, titleElement: Element,
api: any, api: any,
appRef: { appRef: {
@@ -36,41 +37,6 @@ export async function mountSearchBar(
const searchButton = document.createElement("div"); const searchButton = document.createElement("div");
searchButton.className = "search-trigger"; searchButton.className = "search-trigger";
const searchIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
searchIcon.setAttribute("xmlns", "http://www.w3.org/2000/svg");
searchIcon.setAttribute("width", "16");
searchIcon.setAttribute("height", "16");
searchIcon.setAttribute("viewBox", "0 0 24 24");
searchIcon.setAttribute("fill", "none");
searchIcon.setAttribute("stroke", "currentColor");
searchIcon.setAttribute("stroke-width", "2");
searchIcon.setAttribute("stroke-linecap", "round");
searchIcon.setAttribute("stroke-linejoin", "round");
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", "11");
circle.setAttribute("cy", "11");
circle.setAttribute("r", "8");
searchIcon.appendChild(circle);
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", "21");
line.setAttribute("y1", "21");
line.setAttribute("x2", "16.65");
line.setAttribute("y2", "16.65");
searchIcon.appendChild(line);
const searchLabel = document.createElement("p");
searchLabel.textContent = "Quick search...";
const hotkeySpan = document.createElement("span");
hotkeySpan.className = "search-trigger-hotkey";
hotkeySpan.style.marginLeft = "auto";
hotkeySpan.style.display = "flex";
hotkeySpan.style.alignItems = "center";
hotkeySpan.style.color = "#777";
hotkeySpan.style.fontSize = "12px";
const progressBarWrapper = document.createElement("div"); const progressBarWrapper = document.createElement("div");
progressBarWrapper.className = "search-progress-bar-wrapper"; progressBarWrapper.className = "search-progress-bar-wrapper";
@@ -268,10 +234,14 @@ export async function mountSearchBar(
appRef.clearDoneFlashTimer = clearDoneFlashTimer; appRef.clearDoneFlashTimer = clearDoneFlashTimer;
const updateSearchButtonDisplay = () => { const updateSearchButtonDisplay = () => {
hotkeySpan.textContent = hotkeyDisplay; searchButton.innerHTML = /* html */ `
if (!searchButton.contains(searchIcon)) { <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan); <circle cx="11" cy="11" r="8"></circle>
} <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<p>Quick search...</p>
<span style="margin-left: auto; display: flex; align-items: center; color: #777; font-size: 12px;">${hotkeyDisplay}</span>
`;
}; };
updateSearchButtonDisplay(); updateSearchButtonDisplay();
@@ -304,7 +274,6 @@ export async function mountSearchBar(
}); });
try { try {
const { default: renderSvelte } = await import("@/interface/main");
appRef.current = renderSvelte(SearchBar, searchRootShadow, { appRef.current = renderSvelte(SearchBar, searchRootShadow, {
transparencyEffects: api.settings.transparencyEffects ? true : false, transparencyEffects: api.settings.transparencyEffects ? true : false,
showRecentFirst: api.settings.showRecentFirst, showRecentFirst: api.settings.showRecentFirst,
@@ -184,56 +184,6 @@ export async function put(
} }
} }
/**
* Apply puts and deletes in a single readwrite transaction.
*/
export async function applyStoreDiff(
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
if (puts.length === 0 && removeKeys.length === 0) return;
try {
const db = await openDB();
if (!db.objectStoreNames.contains(store)) {
await upgradeDB(store);
const upgradedDb = await openDB();
await runStoreDiffTransaction(upgradedDb, store, puts, removeKeys);
return;
}
await runStoreDiffTransaction(db, store, puts, removeKeys);
} catch (error) {
console.error(`Error in applyStoreDiff for store ${store}:`, error);
throw error;
}
}
function runStoreDiffTransaction(
db: IDBDatabase,
store: string,
puts: Array<{ key: string; value: any }>,
removeKeys: string[],
): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, "readwrite");
const objectStore = tx.objectStore(store);
for (const key of removeKeys) {
objectStore.delete(key);
}
for (const { key, value } of puts) {
objectStore.put(value, key);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function remove(store: string, key: string): Promise<void> { export async function remove(store: string, key: string): Promise<void> {
try { try {
const s = await getStore(store, "readwrite"); const s = await getStore(store, "readwrite");
@@ -1,10 +1,10 @@
import { applyStoreDiff, get, getAll, put, remove, resetDatabase } from "./db"; import { clear, get, getAll, put, remove, resetDatabase } from "./db";
import { jobs } from "./jobs"; import { jobs } from "./jobs";
import { decorateIndexItems } from "./renderComponents"; import { renderComponentMap } from "./renderComponents";
import type { IndexItem, Job, JobContext } from "./types"; import type { IndexItem, Job, JobContext } from "./types";
import { VectorWorkerManager } from "./worker/vectorWorkerManager"; import { VectorWorkerManager } from "./worker/vectorWorkerManager";
import { loadDynamicItems } from "../utils/dynamicItems"; import { loadDynamicItems } from "../utils/dynamicItems";
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils"; import { getVectorizedItemIds } from "./utils";
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion"; import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
const META_STORE = "meta"; const META_STORE = "meta";
@@ -89,64 +89,12 @@ function shouldRun(job: Job, lastRun?: number): boolean {
} }
function getLastRunMeta(jobId: string): Promise<number | undefined> { function getLastRunMeta(jobId: string): Promise<number | undefined> {
return get(META_STORE, jobId).then((rec) => rec?.lastRun); return getAll(META_STORE).then((metaItems) => {
} const match = metaItems.find((m: any) => m.jobId === jobId);
return match?.lastRun;
function indexItemStorageKey(item: IndexItem): string {
return JSON.stringify({
id: item.id,
text: item.text,
category: item.category,
content: item.content,
dateAdded: item.dateAdded,
metadata: item.metadata,
actionId: item.actionId,
renderComponentId: item.renderComponentId,
}); });
} }
function indexItemsEqual(a: IndexItem, b: IndexItem): boolean {
return indexItemStorageKey(a) === indexItemStorageKey(b);
}
async function diffAndStoreItems(
targetStore: string,
items: IndexItem[],
): Promise<void> {
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer] Filtered out ${items.length - validItems.length} invalid items before storing in '${targetStore}'.`,
);
}
const existing = (await getAll(targetStore)) as IndexItem[];
const existingMap = new Map(
existing.filter((i) => i?.id).map((i) => [i.id, i]),
);
const newMap = new Map(validItems.map((i) => [i.id, i]));
const puts: Array<{ key: string; value: IndexItem }> = [];
const removeKeys: string[] = [];
for (const [id, item] of newMap) {
const prev = existingMap.get(id);
if (!prev || !indexItemsEqual(prev, item)) {
puts.push({ key: id, value: item });
}
}
for (const id of existingMap.keys()) {
if (!newMap.has(id)) {
removeKeys.push(id);
}
}
if (puts.length > 0 || removeKeys.length > 0) {
await applyStoreDiff(targetStore, puts, removeKeys);
}
}
async function updateLastRunMeta(jobId: string): Promise<void> { async function updateLastRunMeta(jobId: string): Promise<void> {
await put(META_STORE, { jobId, lastRun: Date.now() }, jobId); await put(META_STORE, { jobId, lastRun: Date.now() }, jobId);
} }
@@ -272,7 +220,6 @@ export async function runIndexing(): Promise<void> {
startHeartbeat(); startHeartbeat();
console.debug("%c[Indexer] Starting indexing...", "color: green"); console.debug("%c[Indexer] Starting indexing...", "color: green");
try {
const jobIds = Object.keys(jobs); const jobIds = Object.keys(jobs);
let completedJobs = 0; let completedJobs = 0;
const totalSteps = jobIds.length + 1; const totalSteps = jobIds.length + 1;
@@ -307,7 +254,14 @@ export async function runIndexing(): Promise<void> {
await getAll(storeId ?? jobId); await getAll(storeId ?? jobId);
const setStoredItems = async (items: IndexItem[], storeId?: string) => { const setStoredItems = async (items: IndexItem[], storeId?: string) => {
const targetStore = storeId ?? jobId; const targetStore = storeId ?? jobId;
await diffAndStoreItems(targetStore, items); await clear(targetStore);
const validItems = items.filter((i) => i && i.id);
if (validItems.length !== items.length) {
console.warn(
`[Indexer Job ${jobId} -> Store ${targetStore}] Filtered out ${items.length - validItems.length} invalid items before storing.`,
);
}
await Promise.all(validItems.map((i) => put(targetStore, i, i.id)));
}; };
const addItem = async (item: IndexItem, storeId?: string) => { const addItem = async (item: IndexItem, storeId?: string) => {
const targetStore = storeId ?? jobId; const targetStore = storeId ?? jobId;
@@ -366,17 +320,6 @@ export async function runIndexing(): Promise<void> {
let allItemsInPrimaryStores = await loadAllStoredItems(); let allItemsInPrimaryStores = await loadAllStoredItems();
const liveItemIds = new Set(allItemsInPrimaryStores.map((item) => item.id));
const prunedCount = await pruneOrphanVectorEmbeddings(liveItemIds);
if (prunedCount > 0) {
try {
const { refreshVectorCache } = await import("../search/vector/vectorSearch");
await refreshVectorCache();
} catch (e) {
console.warn("[Indexer] Failed to refresh vector cache after prune:", e);
}
}
if (allItemsInPrimaryStores.length > 0) { if (allItemsInPrimaryStores.length > 0) {
console.debug( console.debug(
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`, `%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
@@ -491,17 +434,38 @@ export async function runIndexing(): Promise<void> {
); );
} }
stopHeartbeat();
allItemsInPrimaryStores = await loadAllStoredItems(); allItemsInPrimaryStores = await loadAllStoredItems();
const itemsWithComponents = decorateIndexItems(allItemsInPrimaryStores); // Create new objects to avoid XrayWrapper issues in Firefox
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
try {
const jobDef = jobs[item.category] || Object.values(jobs).find(j => j.id === item.category) || jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent = renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
// Deep clone to avoid Firefox XrayWrapper issues with nested objects like metadata
// Use JSON serialization to ensure all nested properties are accessible
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch (e) {
// Fallback to shallow copy if deep clone fails
console.warn("[Indexer] Failed to deep clone item, using shallow copy:", e);
return { ...item, renderComponent };
}
} catch (error) {
// Fallback: return item as-is if modification fails (Firefox XrayWrapper)
console.warn("[Indexer] Failed to add render component to item (Firefox XrayWrapper):", error);
return item;
}
});
loadDynamicItems(itemsWithComponents); loadDynamicItems(itemsWithComponents);
window.dispatchEvent( window.dispatchEvent(new Event("dynamic-items-updated"));
new CustomEvent("dynamic-items-updated", {
detail: { fullRebuild: true },
}),
);
} finally {
stopHeartbeat();
}
} }
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] { function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
@@ -1,5 +1,5 @@
import type { IndexItem } from "./types"; import type { IndexItem } from "./types";
import { getAll, put } from "./db"; import { put, getAll } from "./db";
import { import {
buildIndexItem, buildIndexItem,
extractTextFromValue, extractTextFromValue,
@@ -7,8 +7,10 @@ import {
pickTitle, pickTitle,
} from "./extract"; } from "./extract";
import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api"; import { isSensitiveSeqtaPath, normalizeSeqtaPath } from "./api";
import { mergeDynamicItems } from "../utils/dynamicItems"; import { loadAllStoredItems } from "./indexer";
import { decorateIndexItems } from "./renderComponents"; import { loadDynamicItems } from "../utils/dynamicItems";
import { renderComponentMap } from "./renderComponents";
import { jobs } from "./jobs";
/** /**
* Passive network observer. * Passive network observer.
@@ -39,8 +41,6 @@ const MAX_PER_RESPONSE_TEXT_CHARS = 1500;
let installed = false; let installed = false;
let pendingFlush: ReturnType<typeof setTimeout> | null = null; let pendingFlush: ReturnType<typeof setTimeout> | null = null;
let pendingDirty = false; let pendingDirty = false;
/** Items persisted since the last flush — only these are pushed to the search layer. */
const pendingChangedItems = new Map<string, IndexItem>();
export function isPassiveObserverInstalled(): boolean { export function isPassiveObserverInstalled(): boolean {
return installed; return installed;
@@ -386,7 +386,6 @@ async function persistItems(items: IndexItem[]): Promise<void> {
for (const item of items) { for (const item of items) {
try { try {
await put(STORE_ID, item, item.id); await put(STORE_ID, item, item.id);
pendingChangedItems.set(item.id, item);
} catch (e) { } catch (e) {
console.warn( console.warn(
`[Passive Observer] Failed to persist item ${item.id}:`, `[Passive Observer] Failed to persist item ${item.id}:`,
@@ -410,20 +409,38 @@ function scheduleFlush() {
} }
async function flushDynamicItems(): Promise<void> { async function flushDynamicItems(): Promise<void> {
if (pendingChangedItems.size === 0) return;
const rawChanged = Array.from(pendingChangedItems.values());
pendingChangedItems.clear();
try { try {
const decorated = decorateIndexItems(rawChanged); const all = await loadAllStoredItems();
mergeDynamicItems(decorated); const decorated = all.map((item) => {
try {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
let renderComponent = item.renderComponent;
if (jobDef) {
renderComponent =
renderComponentMap[jobDef.renderComponentId] || renderComponent;
} else if (renderComponentMap[item.renderComponentId]) {
renderComponent = renderComponentMap[item.renderComponentId];
}
try {
const cloned = JSON.parse(JSON.stringify(item));
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
loadDynamicItems(decorated);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("dynamic-items-updated", { new CustomEvent("dynamic-items-updated", {
detail: { detail: {
incremental: true, incremental: true,
jobId: STORE_ID, jobId: STORE_ID,
changedItems: decorated,
streaming: false, streaming: false,
}, },
}), }),
@@ -3,8 +3,6 @@ import AssessmentItem from "../components/items/AssessmentItem.svelte";
import ForumItem from "../components/items/ForumItem.svelte"; import ForumItem from "../components/items/ForumItem.svelte";
import SubjectItem from "../components/items/SubjectItem.svelte"; import SubjectItem from "../components/items/SubjectItem.svelte";
import GenericItem from "../components/items/GenericItem.svelte"; import GenericItem from "../components/items/GenericItem.svelte";
import type { IndexItem } from "./types";
import { jobs } from "./jobs";
export const renderComponentMap: Record<string, typeof SvelteComponent> = { export const renderComponentMap: Record<string, typeof SvelteComponent> = {
assessment: AssessmentItem as unknown as typeof SvelteComponent, assessment: AssessmentItem as unknown as typeof SvelteComponent,
@@ -24,37 +22,3 @@ export const renderComponentMap: Record<string, typeof SvelteComponent> = {
goal: GenericItem as unknown as typeof SvelteComponent, goal: GenericItem as unknown as typeof SvelteComponent,
passive: GenericItem as unknown as typeof SvelteComponent, passive: GenericItem as unknown as typeof SvelteComponent,
}; };
function resolveRenderComponent(item: IndexItem): typeof SvelteComponent | undefined {
const jobDef =
jobs[item.category] ||
Object.values(jobs).find((j) => j.id === item.category) ||
jobs[item.renderComponentId];
if (jobDef) {
return renderComponentMap[jobDef.renderComponentId] || item.renderComponent;
}
if (renderComponentMap[item.renderComponentId]) {
return renderComponentMap[item.renderComponentId];
}
return item.renderComponent;
}
/**
* Attach render components and deep-clone items for search UI (Firefox XrayWrapper).
*/
export function decorateIndexItems(items: IndexItem[]): IndexItem[] {
return items.map((item) => {
try {
const renderComponent = resolveRenderComponent(item);
try {
const cloned = JSON.parse(JSON.stringify(item)) as IndexItem;
cloned.renderComponent = renderComponent;
return cloned;
} catch {
return { ...item, renderComponent };
}
} catch {
return item;
}
});
}
@@ -53,75 +53,6 @@ export async function getVectorizedItemIds(): Promise<Set<string>> {
}); });
} }
const EMBEDDIA_DB = "embeddiaDB";
const EMBEDDIA_STORE = "embeddiaObjectStore";
/**
* Remove vector embeddings for the given item ids from embeddiaDB.
*/
export async function removeVectorEmbeddings(ids: string[]): Promise<void> {
if (ids.length === 0) return;
return new Promise((resolve) => {
const request = indexedDB.open(EMBEDDIA_DB);
request.onerror = () => resolve();
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains(EMBEDDIA_STORE)) {
db.close();
resolve();
return;
}
try {
const transaction = db.transaction([EMBEDDIA_STORE], "readwrite");
const store = transaction.objectStore(EMBEDDIA_STORE);
for (const id of ids) {
store.delete(id);
}
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
resolve();
};
} catch (error) {
console.warn("[Indexer] Failed to remove vector embeddings:", error);
db.close();
resolve();
}
};
});
}
/**
* Delete vector embeddings that no longer exist in the structured index.
* Returns the number of orphaned embeddings removed.
*/
export async function pruneOrphanVectorEmbeddings(
liveItemIds: Set<string>,
): Promise<number> {
const vectorizedIds = await getVectorizedItemIds();
const orphanIds = [...vectorizedIds].filter((id) => !liveItemIds.has(id));
if (orphanIds.length > 0) {
console.debug(
`[Indexer] Pruning ${orphanIds.length} orphaned vector embedding(s)`,
);
await removeVectorEmbeddings(orphanIds);
}
return orphanIds.length;
}
export function htmlToPlainText(rawHtml: string): string { export function htmlToPlainText(rawHtml: string): string {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(rawHtml, "text/html"); const doc = parser.parseFromString(rawHtml, "text/html");
@@ -132,7 +132,6 @@ export async function hybridSearch(
bm25Results: CombinedResult[], bm25Results: CombinedResult[],
query: string, query: string,
options: HybridSearchOptions = {}, options: HybridSearchOptions = {},
precomputedVectorResults?: VectorSearchResult[],
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
@@ -147,10 +146,9 @@ export async function hybridSearch(
if (trimmedQuery.length > 2) { if (trimmedQuery.length > 2) {
try { try {
const vectorTopK = opts.bm25TopK * 2; // Get more vector results than BM25 results to ensure coverage
const vectorSearchResults = // This allows us to find semantic matches that BM25 might have missed
precomputedVectorResults ?? const vectorSearchResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
(await searchVectors(trimmedQuery, vectorTopK));
// Create a map of item ID to vector similarity // Create a map of item ID to vector similarity
const vectorMap = new Map<string, number>(); const vectorMap = new Map<string, number>();
@@ -244,33 +242,20 @@ export async function hybridSearch(
export async function hybridSearchWithExpansion( export async function hybridSearchWithExpansion(
bm25Results: CombinedResult[], bm25Results: CombinedResult[],
query: string, query: string,
allItems: IndexItem[], _allItems: IndexItem[],
options: HybridSearchOptions = {}, options: HybridSearchOptions = {},
): Promise<CombinedResult[]> { ): Promise<CombinedResult[]> {
const opts = { ...DEFAULT_OPTIONS, ...options }; const opts = { ...DEFAULT_OPTIONS, ...options };
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
const liveIndexIds = new Set(allItems.map((item) => item.id));
// First, rerank BM25 results
const rerankedBm25 = await hybridSearch(bm25Results, query, options);
// If query is too short, skip vector expansion
if (trimmedQuery.length <= 2) { if (trimmedQuery.length <= 2) {
return hybridSearch(bm25Results, query, options); return rerankedBm25;
} }
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK * 2);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return hybridSearch(bm25Results, query, options);
}
// Rerank BM25 results using the single vector pass above
const rerankedBm25 = await hybridSearch(
bm25Results,
query,
options,
vectorResults,
);
// For short / single-token queries vector expansion brings in too much // For short / single-token queries vector expansion brings in too much
// noise (and is the main reason results "flicker" between adjacent // noise (and is the main reason results "flicker" between adjacent
// keystrokes). Keep semantic recall for longer queries. // keystrokes). Keep semantic recall for longer queries.
@@ -278,6 +263,15 @@ export async function hybridSearchWithExpansion(
return rerankedBm25.slice(0, opts.finalLimit); return rerankedBm25.slice(0, opts.finalLimit);
} }
// Get vector search results
let vectorResults: VectorSearchResult[] = [];
try {
vectorResults = await searchVectors(trimmedQuery, opts.bm25TopK);
} catch (e) {
console.warn("[Hybrid Search] Vector search failed:", e);
return rerankedBm25;
}
// Find vector results that weren't in BM25 results // Find vector results that weren't in BM25 results
const bm25Ids = new Set(bm25Results.map(r => r.item.id)); const bm25Ids = new Set(bm25Results.map(r => r.item.id));
const vectorOnlyResults: CombinedResult[] = []; const vectorOnlyResults: CombinedResult[] = [];
@@ -304,9 +298,6 @@ export async function hybridSearchWithExpansion(
vectorResults.forEach(v => { vectorResults.forEach(v => {
if (bm25Ids.has(v.object.id)) return; if (bm25Ids.has(v.object.id)) return;
// Drop stale vector hits for items no longer in the live structured index.
if (!liveIndexIds.has(v.object.id)) return;
// This is a semantic match that BM25 missed // This is a semantic match that BM25 missed
const item = v.object; const item = v.object;
@@ -101,94 +101,6 @@ if (typeof window !== 'undefined') {
}); });
} }
/** Rebuild Fuse when incremental delta exceeds this count. */
export const INCREMENTAL_FUSE_REBUILD_THRESHOLD = 75;
export const DYNAMIC_FUSE_OPTIONS = {
keys: [
{ name: "text", weight: 3 },
{ name: "content", weight: 1 },
{ name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 },
{ name: "metadata.subjectCode", weight: 1.6 },
{ name: "metadata.subject", weight: 1.4 },
{ name: "metadata.courseCode", weight: 1.2 },
{ name: "metadata.filename", weight: 1.2 },
{ name: "metadata.author", weight: 0.8 },
{ name: "metadata.authorName", weight: 0.8 },
{ name: "metadata.label", weight: 0.6 },
{ name: "metadata.categoryName", weight: 0.6 },
{ name: "metadata.entityType", weight: 0.4 },
],
includeScore: true,
includeMatches: true,
threshold: 0.5,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
ignoreLocation: true,
findAllMatches: true,
shouldSort: true,
} as const;
export interface DynamicItemsUpdatedDetail {
incremental?: boolean;
fullRebuild?: boolean;
jobId?: string;
changedItems?: IndexItem[];
removedIds?: string[];
vectorUpdate?: boolean;
streaming?: boolean;
newItemCount?: number;
}
export function createDynamicContentFuse(
items: IndexItem[],
): Fuse<IndexItem> {
return new Fuse(
dedupeIndexItemsForSearch(items),
DYNAMIC_FUSE_OPTIONS,
) as Fuse<IndexItem>;
}
/**
* Apply an incremental dynamic-item delta to an existing Fuse index.
* Returns null when a full rebuild is recommended.
*/
export function applyDynamicIndexDelta(
fuse: Fuse<IndexItem> | undefined,
idToItemMap: Map<string, IndexItem>,
detail: DynamicItemsUpdatedDetail,
): Fuse<IndexItem> | null {
const changedItems = detail.changedItems ?? [];
const removedIds = detail.removedIds ?? [];
const deltaSize = changedItems.length + removedIds.length;
if (
detail.fullRebuild ||
!fuse ||
idToItemMap.size === 0 ||
deltaSize === 0 ||
deltaSize > INCREMENTAL_FUSE_REBUILD_THRESHOLD
) {
return null;
}
for (const id of removedIds) {
fuse.remove((item) => item.id === id);
idToItemMap.delete(id);
}
for (const item of changedItems) {
fuse.remove((existing) => existing.id === item.id);
fuse.add(item);
idToItemMap.set(item.id, item);
}
clearSearchCache();
return fuse;
}
export function createSearchIndexes() { export function createSearchIndexes() {
clearSearchCache(); clearSearchCache();
const commands = getStaticCommands(); const commands = getStaticCommands();
@@ -206,9 +118,49 @@ export function createSearchIndexes() {
findAllMatches: false, // Performance optimization findAllMatches: false, // Performance optimization
}; };
// Optimized dynamic content search options.
// The expanded corpus mixes structured entities (assessments, subjects)
// with free-form text (course content, notices, folio bodies, passive
// captures) so we list a broad set of metadata keys while keeping titles
// dominant in the ranking.
// NOTE: metadata.route is intentionally excluded. Raw API paths like
// `/seqta/student/load/message/people` should never influence ranking — they
// historically caused passive-capture support records to bubble up above
// real assessments when the user typed substrings that happened to appear in
// the path.
const dynamicOptions = {
keys: [
{ name: "text", weight: 3 }, // Title is king
{ name: "content", weight: 1 },
{ name: "category", weight: 0.4 },
{ name: "metadata.subjectName", weight: 1.6 },
{ name: "metadata.subjectCode", weight: 1.6 },
{ name: "metadata.subject", weight: 1.4 },
{ name: "metadata.courseCode", weight: 1.2 },
{ name: "metadata.filename", weight: 1.2 },
{ name: "metadata.author", weight: 0.8 },
{ name: "metadata.authorName", weight: 0.8 },
{ name: "metadata.label", weight: 0.6 },
{ name: "metadata.categoryName", weight: 0.6 },
{ name: "metadata.entityType", weight: 0.4 },
],
includeScore: true,
includeMatches: true,
threshold: 0.5,
minMatchCharLength: 2,
distance: 100,
useExtendedSearch: true,
ignoreLocation: true,
findAllMatches: true,
shouldSort: true,
};
return { return {
commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>, commandsFuse: new Fuse(commands, commandOptions) as Fuse<StaticCommandItem>,
dynamicContentFuse: createDynamicContentFuse(dynamicItems), dynamicContentFuse: new Fuse(
dynamicItems,
dynamicOptions,
) as Fuse<IndexItem>,
commands, commands,
dynamicItems, dynamicItems,
}; };
@@ -1,4 +1,4 @@
import { create, all, typeOf as mathTypeOf, format as mathFormat } from 'mathjs'; import * as math from 'mathjs';
import { unitFullNames } from './unitMap'; import { unitFullNames } from './unitMap';
export interface CalculatorResult { export interface CalculatorResult {
@@ -10,42 +10,66 @@ export interface CalculatorResult {
error?: string; error?: string;
} }
/** Hard cap on calculator input length to limit parse/eval cost. */ const expandedMath = math.create(math.all);
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
/** expandedMath.import({
* Functions safe to replace with stubs. Do not block type constructors five: 5,
* (`complex`, `typed`, `fraction`, `bignumber`, `sparse`) or parse pipeline ten: 10,
* (`parse`, `compile`, `parser`) mathjs needs those internally and three: 3,
* `evaluate()` depends on them. four: 4,
*/ eight: 8,
const BLOCKED_MATH_FUNCTIONS = [ sixteen: 16,
'import', twenty: 20,
'createUnit', twentyfive: 25,
'random', fifty: 50,
'pickRandom', hundred: 100,
'chain', plus: (a: number, b: number) => a + b,
'help', minus: (a: number, b: number) => a - b,
] as const; times: (a: number, b: number) => a * b,
divided: (a: number, b: number) => a / b,
power: (a: number, b: number) => Math.pow(a, b),
half: (a: number) => a / 2,
double: (a: number) => a * 2,
quarter: (a: number) => a / 4,
function createSandboxedMath() { // String functions
const sandbox = create(all); length: (str: string) => str.length,
const blockFn = () => { concat: (...args: string[]) => args.join(''),
throw new Error('Function not allowed'); uppercase: (str: string) => str.toUpperCase(),
}; lowercase: (str: string) => str.toLowerCase(),
const blocked: Record<string, () => never> = {}; substr: (str: string, start: number, length: number) => str.substr(start, length),
for (const name of BLOCKED_MATH_FUNCTIONS) {
blocked[name] = blockFn;
}
sandbox.import(blocked, { override: true });
return sandbox;
}
const calculatorMath = createSandboxedMath(); // Random functions
randomInt: (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min,
// Comparison and Boolean operations
and: (a: boolean, b: boolean) => a && b,
or: (a: boolean, b: boolean) => a || b,
not: (a: boolean) => !a,
// Combinatorics
permutations: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nPr: (n: number, r: number) => expandedMath.combinations(n, r) * expandedMath.factorial(r),
nCr: (n: number, r: number) => expandedMath.combinations(n, r),
// Number theory
gcd: (a: number, b: number) => expandedMath.gcd(a, b),
lcm: (a: number, b: number) => expandedMath.lcm(a, b),
// Precision functions
precision: (num: number, digits: number) => parseFloat(num.toPrecision(digits)),
fix: (num: number, digits: number) => parseFloat(num.toFixed(digits)),
// Percentage operations
percent: (value: number) => value / 100,
// Financial operations
compound: (principal: number, rate: number, time: number) => principal * Math.pow(1 + rate, time),
}, { override: true });
function detectUnit(expression: string): string { function detectUnit(expression: string): string {
try { try {
const unit = calculatorMath.unit(expression); const unit = expandedMath.unit(expression);
if (unit) { if (unit) {
const unitStr = unit.formatUnits(); const unitStr = unit.formatUnits();
return unitFullNames[unitStr] || unitStr; return unitFullNames[unitStr] || unitStr;
@@ -96,9 +120,9 @@ function tryCompleteExpression(expression: string): string | null {
// Handle cases like "4 + 3 *" -> evaluate "4 + 3" // Handle cases like "4 + 3 *" -> evaluate "4 + 3"
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) { if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
try { try {
const result = calculatorMath.evaluate(partial); const result = expandedMath.evaluate(partial);
if (typeof result === 'number' && !isNaN(result)) { if (typeof result === 'number' && !isNaN(result)) {
return calculatorMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 }); return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
} }
} catch (e) { } catch (e) {
// Continue to other attempts // Continue to other attempts
@@ -124,17 +148,6 @@ export function calculateExpression(input: string): CalculatorResult {
}; };
} }
if (trimmed.length > CALCULATOR_MAX_INPUT_LENGTH) {
return {
result: null,
isValid: false,
isPartial: false,
inputUnit: '',
outputUnit: '',
error: `Expression too long (max ${CALCULATOR_MAX_INPUT_LENGTH} characters)`,
};
}
// Check if this looks like a math expression at all // Check if this looks like a math expression at all
if (!isLikelyMathExpression(trimmed)) { if (!isLikelyMathExpression(trimmed)) {
return { return {
@@ -148,23 +161,23 @@ export function calculateExpression(input: string): CalculatorResult {
try { try {
// First try to evaluate the expression as-is // First try to evaluate the expression as-is
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^')); const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
if (evaluated !== undefined) { if (evaluated !== undefined) {
let result: string; let result: string;
let inputUnit = ''; let inputUnit = '';
let outputUnit = ''; let outputUnit = '';
if (mathTypeOf(evaluated) === 'Unit') { if (math.typeOf(evaluated) === 'Unit') {
// Handle unit conversion results // Handle unit conversion results
result = calculatorMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
inputUnit = detectUnit(trimmed); inputUnit = detectUnit(trimmed);
outputUnit = detectUnit(result); outputUnit = detectUnit(result);
} else if (typeof evaluated === 'number') { } else if (typeof evaluated === 'number') {
// Handle regular numbers // Handle regular numbers
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} else { } else {
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 }); result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
} }
return { return {
@@ -16,29 +16,12 @@ export interface DynamicContentItem {
let dynamicItems: IndexItem[] = []; let dynamicItems: IndexItem[] = [];
/** /**
* Loads a new set of dynamic items (full replace). * Loads a new set of dynamic items.
*/ */
export function loadDynamicItems(items: IndexItem[]) { export function loadDynamicItems(items: IndexItem[]) {
dynamicItems = items; dynamicItems = items;
} }
/**
* Merge changed items and remove deleted ids without reloading the full corpus.
*/
export function mergeDynamicItems(
changedItems: IndexItem[],
removedIds: string[] = [],
): void {
if (changedItems.length === 0 && removedIds.length === 0) return;
const removeSet = new Set(removedIds);
const changeMap = new Map(changedItems.map((item) => [item.id, item]));
const kept = dynamicItems.filter(
(item) => !removeSet.has(item.id) && !changeMap.has(item.id),
);
dynamicItems = [...kept, ...changedItems];
}
/** /**
* Returns all currently loaded dynamic items. * Returns all currently loaded dynamic items.
*/ */
@@ -6,14 +6,6 @@ export interface ParsedHotkey {
key: string; key: string;
} }
/** Single-key allowlist: a-z, 0-9, and F1F12 only. */
const ALLOWED_HOTKEY_KEY = /^([a-z0-9]|f(1[0-2]|[1-9]))$/;
export function isAllowedHotkeyKey(key: string): boolean {
if (!key) return false;
return ALLOWED_HOTKEY_KEY.test(key.toLowerCase());
}
export function parseHotkey(hotkeyString: string): ParsedHotkey { export function parseHotkey(hotkeyString: string): ParsedHotkey {
const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0); const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0);
@@ -76,14 +68,14 @@ export function formatHotkeyForDisplay(hotkeyString: string): string {
parts.push(isMac ? '⇧' : 'Shift'); parts.push(isMac ? '⇧' : 'Shift');
} }
if (parsed.key && isAllowedHotkeyKey(parsed.key)) { if (parsed.key) {
parts.push(parsed.key.toUpperCase()); parts.push(parsed.key.toUpperCase());
} }
return parts.join(isMac ? ' ' : '+'); return parts.join(isMac ? ' ' : '+');
} catch (error) { } catch (error) {
console.warn('Invalid hotkey string:', hotkeyString); console.warn('Invalid hotkey string:', hotkeyString);
return 'Ctrl+K'; return hotkeyString; // Fallback to original string
} }
} }
@@ -92,7 +84,7 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
const parsed = parseHotkey(hotkeyString); const parsed = parseHotkey(hotkeyString);
// If no key is specified, don't match anything // If no key is specified, don't match anything
if (!parsed.key || !isAllowedHotkeyKey(parsed.key)) { if (!parsed.key) {
return false; return false;
} }
@@ -119,7 +111,7 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
export function isValidHotkey(hotkeyString: string): boolean { export function isValidHotkey(hotkeyString: string): boolean {
try { try {
const parsed = parseHotkey(hotkeyString); const parsed = parseHotkey(hotkeyString);
return parsed.key.length > 0 && isAllowedHotkeyKey(parsed.key); return parsed.key.length > 0;
} catch (error) { } catch (error) {
return false; return false;
} }
@@ -268,7 +268,7 @@
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)} xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
yScale={yScale} yScale={yScale()}
x="grade" x="grade"
@@ -310,6 +310,8 @@
y: { type: "tween", duration: 600, easing: cubicInOut }, y: { type: "tween", duration: 600, easing: cubicInOut },
height: { type: "tween", duration: 600, easing: cubicInOut },
}, },
}, },
@@ -35,15 +35,6 @@
), ),
); );
$effect(() => {
sortedData.length;
itemsPerPage;
const maxPage = Math.max(0, pageCount - 1);
if (currentPage > maxPage) {
currentPage = maxPage;
}
});
function toggleSort(column: keyof Assessment) { function toggleSort(column: keyof Assessment) {
if (sortColumn === column) { if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc"; sortDirection = sortDirection === "asc" ? "desc" : "asc";
@@ -53,9 +53,8 @@
const [minG, maxG] = gradeRange; const [minG, maxG] = gradeRange;
return analyticsData.filter((a) => { return analyticsData.filter((a) => {
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false; if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
if (a.finalGrade !== undefined) { const grade = a.finalGrade ?? -1;
if (a.finalGrade < minG || a.finalGrade > maxG) return false; if (grade < minG || grade > maxG) return false;
}
if ( if (
filterSearch && filterSearch &&
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) && !a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
+3 -15
View File
@@ -24,9 +24,6 @@ async function fetchJSON(url: string, body: Record<string, unknown>) {
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return res.json(); return res.json();
} }
@@ -257,19 +254,10 @@ async function loadAllPast(
const results: Record<string, unknown>[][] = []; const results: Record<string, unknown>[][] = [];
for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) { for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) {
const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY); const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY);
const batchResults = await Promise.allSettled( const batchResults = await Promise.all(
batch.map((s) => loadPastForSubject(studentId, s)), batch.map((s) => loadPastForSubject(studentId, s)),
); );
for (const result of batchResults) { results.push(...batchResults);
if (result.status === "fulfilled") {
results.push(result.value);
} else {
console.error(
"[BetterSEQTA+] Past assessments fetch failed:",
result.reason,
);
}
}
} }
return results.flat(); return results.flat();
} }
@@ -307,7 +295,7 @@ function mergeRawAssessments(
} }
export async function getStudentId(): Promise<number> { export async function getStudentId(): Promise<number> {
const info = await getUserInfo({ validateSession: true }); const info = await getUserInfo();
const id = Number(info?.id); const id = Number(info?.id);
if (!id || isNaN(id)) throw new Error("Could not resolve student ID"); if (!id || isNaN(id)) throw new Error("Could not resolve student ID");
return id; return id;
+5 -44
View File
@@ -67,44 +67,6 @@ 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);
} }
function isAllowedFolderColor(color: unknown): color is string {
return typeof color === "string" && FOLDER_COLORS.includes(color);
}
function isAllowedFolderIcon(icon: unknown): icon is string {
return typeof icon === "string" && FOLDER_HEROICONS.includes(icon);
}
function normalizeFolder(folder: Folder): Folder {
return {
id: typeof folder.id === "string" && folder.id ? folder.id : generateId(),
name: typeof folder.name === "string" ? folder.name.trim().slice(0, 30) : "Folder",
color: isAllowedFolderColor(folder.color) ? folder.color : FOLDER_COLORS[0],
emoji: isAllowedFolderIcon(folder.emoji) ? folder.emoji : FOLDER_HEROICONS[0],
};
}
function setSvgIconContent(parent: HTMLElement, svgMarkup: string): void {
parent.replaceChildren();
const template = document.createElement("template");
template.innerHTML = svgMarkup.trim();
const node = template.content.firstElementChild;
if (node) parent.appendChild(node);
}
function appendFolderBadgeContent(badge: HTMLElement, folder: Folder): void {
badge.replaceChildren();
if (folder.emoji) {
const iconWrap = document.createElement("span");
iconWrap.style.display = "inline-flex";
iconWrap.style.verticalAlign = "middle";
iconWrap.style.marginRight = "2px";
setSvgIconContent(iconWrap, folder.emoji);
badge.appendChild(iconWrap);
}
badge.appendChild(document.createTextNode(folder.name));
}
const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = { const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFoldersStorage> = {
id: "messageFolders", id: "messageFolders",
name: "Message Folders", name: "Message Folders",
@@ -133,8 +95,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
let foldedSection: HTMLElement | null = null; let foldedSection: HTMLElement | null = null;
const unregisters: Array<{ unregister: () => void }> = []; const unregisters: Array<{ unregister: () => void }> = [];
const getFolders = (): Folder[] => const getFolders = (): Folder[] => api.storage.folders ?? [];
(api.storage.folders ?? []).map((folder) => normalizeFolder(folder));
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {}; const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
const saveFolders = (folders: Folder[]) => { const saveFolders = (folders: Folder[]) => {
@@ -337,7 +298,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const iconSpan = document.createElement("span"); const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon"; iconSpan.className = "bsplus-folder-icon";
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]); iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
item.appendChild(iconSpan); item.appendChild(iconSpan);
const name = document.createElement("span"); const name = document.createElement("span");
@@ -661,7 +622,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
const iconSpan = document.createElement("span"); const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon"; iconSpan.className = "bsplus-folder-icon";
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]); 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;
@@ -764,7 +725,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
dot.style.background = folder.color; dot.style.background = folder.color;
const iconSpan = document.createElement("span"); const iconSpan = document.createElement("span");
iconSpan.className = "bsplus-folder-icon"; iconSpan.className = "bsplus-folder-icon";
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]); 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(dot); item.appendChild(dot);
@@ -849,7 +810,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;
appendFolderBadgeContent(badge, folder); badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${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();
@@ -62,10 +62,6 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
}, },
); );
if (!response.ok) {
throw new Error(`Heartbeat HTTP ${response.status}`);
}
const data = await response.json(); const data = await response.json();
// Store notification count for history // Store notification count for history
+6 -7
View File
@@ -1,5 +1,7 @@
import renderSvelte from "@/interface/main";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager"; import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import { unmount } from "svelte"; import { unmount } from "svelte";
import themeCreator from "@/interface/pages/themeCreator.svelte";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
let themeCreatorSvelteApp: any = null; let themeCreatorSvelteApp: any = null;
@@ -9,15 +11,10 @@ let themeCreatorSvelteApp: any = null;
* @param themeID - The ID of the theme to load in the Theme Creator * @param themeID - The ID of the theme to load in the Theme Creator
* @returns void * @returns void
*/ */
export async function OpenThemeCreator(themeID: string = "") { export function OpenThemeCreator(themeID: string = "") {
CloseThemeCreator(); CloseThemeCreator();
const [{ default: renderSvelte }, { default: themeCreator }] = // Only store original color if we're not editing an existing theme
await Promise.all([
import("@/interface/main"),
import("@/interface/pages/themeCreator.svelte"),
]);
localStorage.setItem("themeCreatorOpen", "true"); localStorage.setItem("themeCreatorOpen", "true");
if (!themeID) { if (!themeID) {
localStorage.setItem("originalPreviewColor", settingsState.selectedColor); localStorage.setItem("originalPreviewColor", settingsState.selectedColor);
@@ -37,6 +34,7 @@ export async function OpenThemeCreator(themeID: string = "") {
const mainContent = document.querySelector("#container") as HTMLDivElement; const mainContent = document.querySelector("#container") as HTMLDivElement;
if (mainContent) mainContent.style.width = `calc(100% - ${width})`; if (mainContent) mainContent.style.width = `calc(100% - ${width})`;
// close button
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.classList.add("themeCloseButton"); closeButton.classList.add("themeCloseButton");
closeButton.textContent = "×"; closeButton.textContent = "×";
@@ -94,6 +92,7 @@ export async function OpenThemeCreator(themeID: string = "") {
* @returns void * @returns void
*/ */
export function CloseThemeCreator() { export function CloseThemeCreator() {
// Remove the stored flag
localStorage.removeItem("themeCreatorOpen"); localStorage.removeItem("themeCreatorOpen");
const themeCreator = document.getElementById("themeCreator"); const themeCreator = document.getElementById("themeCreator");
+4 -6
View File
@@ -10,8 +10,8 @@ import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloud
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import debounce from "@/seqta/utils/debounce"; import debounce from "@/seqta/utils/debounce";
import { themeUpdates } from "@/interface/hooks/ThemeUpdates"; import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { getApiBase } from "@/seqta/utils/DevApiBase"; import { getApiBase } from "@/seqta/utils/DevApiBase";
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { updateAllColors } from "@/seqta/ui/colors/Manager";
import { import {
clearCustomThemeAdaptiveCssVariables, clearCustomThemeAdaptiveCssVariables,
@@ -667,12 +667,8 @@ export class ThemeManager {
if (!downloadData?.success || !downloadData?.data?.theme_json_url) { if (!downloadData?.success || !downloadData?.data?.theme_json_url) {
throw new Error("Failed to get theme download URL"); throw new Error("Failed to get theme download URL");
} }
const themeJsonUrl = downloadData.data.theme_json_url;
if (!isAllowedFetchUrl(themeJsonUrl)) {
throw new Error("Theme download URL not allowed");
}
themeData = (await this.fetchFromUrl( themeData = (await this.fetchFromUrl(
themeJsonUrl, downloadData.data.theme_json_url,
)) as ThemeContent; )) as ThemeContent;
} catch (apiError) { } catch (apiError) {
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError); console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
@@ -800,8 +796,10 @@ export class ThemeManager {
this.storeUpdateCheckRunning = true; this.storeUpdateCheckRunning = true;
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now())); localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
try { try {
const token = await cloudAuth.getStoredToken();
const res = (await browser.runtime.sendMessage({ const res = (await browser.runtime.sendMessage({
type: "fetchThemes", type: "fetchThemes",
token: token ?? undefined,
})) as { })) as {
success?: boolean; success?: boolean;
data?: { themes?: Array<{ id: string; updated_at?: number }> }; data?: { themes?: Array<{ id: string; updated_at?: number }> };
-4
View File
@@ -84,8 +84,6 @@ async function handleTimetable(): Promise<void> {
} }
function handleTimetableZoom(): void { function handleTimetableZoom(): void {
if (document.querySelector(".timetable-zoom-controls")) return;
console.log("Initializing timetable zoom controls"); console.log("Initializing timetable zoom controls");
// Create zoom controls // Create zoom controls
@@ -132,8 +130,6 @@ function handleTimetableZoom(): void {
} }
function handleTimetableAssessmentHide(): void { function handleTimetableAssessmentHide(): void {
if (document.querySelector(".timetable-hide-controls")) return;
const hideControls = document.createElement("div"); const hideControls = document.createElement("div");
hideControls.className = "timetable-hide-controls"; hideControls.className = "timetable-hide-controls";
+41 -63
View File
@@ -12,7 +12,6 @@ import { eventManager } from "@/seqta/utils/listeners/EventManager";
import ReactFiber from "@/seqta/utils/ReactFiber"; import ReactFiber from "@/seqta/utils/ReactFiber";
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import type { SettingsState } from "@/types/storage";
function createSEQTAAPI(): SEQTAAPI { function createSEQTAAPI(): SEQTAAPI {
return { return {
@@ -86,10 +85,7 @@ function createSEQTAAPI(): SEQTAAPI {
*/ */
function createSettingsAPI<T extends PluginSettings>( function createSettingsAPI<T extends PluginSettings>(
plugin: Plugin<T>, plugin: Plugin<T>,
): { ): SettingsAPI<T> & { loaded: Promise<void> } {
settings: SettingsAPI<T> & { loaded: Promise<void> };
dispose: () => void;
} {
const storageKey = `plugin.${plugin.id}.settings`; const storageKey = `plugin.${plugin.id}.settings`;
const listeners = new Map<keyof T, Set<(value: any) => void>>(); const listeners = new Map<keyof T, Set<(value: any) => void>>();
@@ -150,28 +146,26 @@ function createSettingsAPI<T extends PluginSettings>(
settingsWithMeta.loaded = loaded; settingsWithMeta.loaded = loaded;
const handleSettingsChange = (newValue: unknown) => { // Listen for storage changes and update settingsWithMeta
if (!newValue || typeof newValue !== "object") return; const handleStorageChange = (
changes: { [key: string]: browser.Storage.StorageChange },
area: string,
) => {
if (area !== "local" || !(storageKey in changes)) return;
const newSettings = newValue as Partial<Record<keyof T, any>>; const newValue = changes[storageKey].newValue as
for (const key in newSettings) { | Partial<Record<keyof T, any>>
| undefined;
if (!newValue) return;
for (const key in newValue) {
const typedKey = key as keyof T; const typedKey = key as keyof T;
settingsWithMeta[typedKey] = newSettings[typedKey]; settingsWithMeta[typedKey] = newValue[typedKey];
listeners.get(typedKey)?.forEach((cb) => cb(newSettings[typedKey])); listeners.get(typedKey)?.forEach((cb) => cb(newValue[typedKey]));
} }
}; };
settingsState.register( browser.storage.onChanged.addListener(handleStorageChange);
storageKey as keyof SettingsState,
handleSettingsChange,
);
const dispose = () => {
settingsState.unregister(
storageKey as keyof SettingsState,
handleSettingsChange,
);
};
const proxy = new Proxy(settingsWithMeta, { const proxy = new Proxy(settingsWithMeta, {
get(target, prop) { get(target, prop) {
@@ -189,17 +183,6 @@ function createSettingsAPI<T extends PluginSettings>(
dataToStore[key] = target[key]; dataToStore[key] = target[key];
} }
// Preserve enabled flag managed separately for disableToggle plugins
if (plugin.disableToggle) {
const allSettings = settingsState.getAll() as Record<string, unknown>;
const existing = allSettings[storageKey] as
| { enabled?: boolean }
| undefined;
if (existing?.enabled !== undefined) {
dataToStore.enabled = existing.enabled;
}
}
browser.storage.local.set({ [storageKey]: dataToStore }); browser.storage.local.set({ [storageKey]: dataToStore });
listeners.get(prop as keyof T)?.forEach((cb) => cb(value)); listeners.get(prop as keyof T)?.forEach((cb) => cb(value));
@@ -207,18 +190,18 @@ function createSettingsAPI<T extends PluginSettings>(
}, },
}) as SettingsAPI<T> & { loaded: Promise<void> }; }) as SettingsAPI<T> & { loaded: Promise<void> };
return { settings: proxy, dispose }; return proxy;
} }
function createStorageAPI<T = any>( function createStorageAPI<T = any>(
pluginId: string, pluginId: string,
): { ): StorageAPI<T> & { [K in keyof T]: T[K] } {
storage: StorageAPI<T> & { [K in keyof T]: T[K] };
dispose: () => void;
} {
const prefix = `plugin.${pluginId}.storage.`; const prefix = `plugin.${pluginId}.storage.`;
const cache: Record<string, any> = {}; const cache: Record<string, any> = {};
const listeners = new Map<string, Set<(value: any) => void>>(); const listeners = new Map<string, Set<(value: any) => void>>();
const storageListeners = new Set<
(changes: { [key: string]: any }, area: string) => void
>();
// Load all existing storage values for this plugin // Load all existing storage values for this plugin
const loadStoragePromise = (async () => { const loadStoragePromise = (async () => {
@@ -240,26 +223,30 @@ function createStorageAPI<T = any>(
} }
})(); })();
// Listen for storage changes
const handleStorageChange = ( const handleStorageChange = (
newValue: unknown, changes: { [key: string]: any },
_oldValue: unknown, area: string,
key: string,
) => { ) => {
if (!key.startsWith(prefix)) return; if (area === "local") {
Object.entries(changes).forEach(([key, change]) => {
if (key.startsWith(prefix)) {
const shortKey = key.slice(prefix.length);
cache[shortKey] = change.newValue;
const shortKey = key.slice(prefix.length); // Notify listeners
cache[shortKey] = newValue; listeners
listeners.get(shortKey)?.forEach((callback) => callback(newValue)); .get(shortKey)
}; ?.forEach((callback) => callback(change.newValue));
}
settingsState.registerGlobal(handleStorageChange); });
}
const dispose = () => {
settingsState.unregisterGlobal(handleStorageChange);
}; };
browser.storage.onChanged.addListener(handleStorageChange);
storageListeners.add(handleStorageChange);
// Create the proxy for direct property access // Create the proxy for direct property access
const storage = new Proxy(cache, { return new Proxy(cache, {
get(target, prop: string) { get(target, prop: string) {
if (prop === "onChange") { if (prop === "onChange") {
return (key: keyof T, callback: (value: T[keyof T]) => void) => { return (key: keyof T, callback: (value: T[keyof T]) => void) => {
@@ -301,8 +288,6 @@ function createStorageAPI<T = any>(
return true; return true;
}, },
}) as StorageAPI<T> & { [K in keyof T]: T[K] }; }) as StorageAPI<T> & { [K in keyof T]: T[K] };
return { storage, dispose };
} }
function createEventsAPI(pluginId: string): EventsAPI { function createEventsAPI(pluginId: string): EventsAPI {
@@ -372,17 +357,10 @@ function createEventsAPI(pluginId: string): EventsAPI {
export function createPluginAPI<T extends PluginSettings, S = any>( export function createPluginAPI<T extends PluginSettings, S = any>(
plugin: Plugin<T, S>, plugin: Plugin<T, S>,
): PluginAPI<T, S> { ): PluginAPI<T, S> {
const { settings, dispose: disposeSettings } = createSettingsAPI(plugin);
const { storage, dispose: disposeStorage } = createStorageAPI<S>(plugin.id);
return { return {
seqta: createSEQTAAPI(), seqta: createSEQTAAPI(),
settings, settings: createSettingsAPI(plugin),
storage, storage: createStorageAPI<S>(plugin.id),
events: createEventsAPI(plugin.id), events: createEventsAPI(plugin.id),
dispose: () => {
disposeSettings();
disposeStorage();
},
}; };
} }
+15 -65
View File
@@ -23,23 +23,6 @@ interface StorageChange<T = any> {
newValue?: T; newValue?: T;
} }
/** Phased plugin startup: critical UI first, light DOM next, heavy plugins last. */
const PLUGIN_START_PHASES: readonly string[][] = [
["themes", "animated-background"],
[
"timetable",
"timetableEdit",
"notificationCollector",
"enhanced-navigation",
"assessments-overview",
"assessments-average",
"messageFolders",
"profile-picture",
"background-music",
],
["global-search", "grade-analytics"],
];
/** /**
* Singleton class responsible for the entire lifecycle of plugins. * Singleton class responsible for the entire lifecycle of plugins.
* This includes registration, starting, stopping, event dispatching, * This includes registration, starting, stopping, event dispatching,
@@ -52,7 +35,6 @@ export class PluginManager {
private runningPlugins: Map<string, boolean> = new Map(); private runningPlugins: Map<string, boolean> = new Map();
private eventBacklog: Map<string, any[]> = new Map(); private eventBacklog: Map<string, any[]> = new Map();
private cleanupFunctions: Map<string, () => void> = new Map(); private cleanupFunctions: Map<string, () => void> = new Map();
private apiDisposers: Map<string, () => void> = new Map();
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map(); private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private styleElements: Map<string, HTMLStyleElement> = new Map(); private styleElements: Map<string, HTMLStyleElement> = new Map();
@@ -166,7 +148,6 @@ export class PluginManager {
try { try {
const api = createPluginAPI(plugin); const api = createPluginAPI(plugin);
this.apiDisposers.set(pluginId, api.dispose);
// Check if plugin is enabled before starting // Check if plugin is enabled before starting
if (plugin.disableToggle) { if (plugin.disableToggle) {
@@ -177,7 +158,6 @@ export class PluginManager {
const enabled = const enabled =
pluginSettings?.enabled ?? plugin.defaultEnabled ?? true; pluginSettings?.enabled ?? plugin.defaultEnabled ?? true;
if (!enabled) { if (!enabled) {
this.disposePluginAPI(pluginId);
console.info( console.info(
`Plugin "${pluginId}" is disabled, skipping initialization`, `Plugin "${pluginId}" is disabled, skipping initialization`,
); );
@@ -206,8 +186,6 @@ export class PluginManager {
// Process any backlogged events // Process any backlogged events
await this.processBackloggedEvents(pluginId); await this.processBackloggedEvents(pluginId);
} catch (error) { } catch (error) {
this.removePluginStyles(pluginId);
this.disposePluginAPI(pluginId);
console.error( console.error(
`[BetterSEQTA+] Failed to start plugin ${pluginId}:`, `[BetterSEQTA+] Failed to start plugin ${pluginId}:`,
error, error,
@@ -216,55 +194,23 @@ export class PluginManager {
} }
} }
private removePluginStyles(pluginId: string): void {
const styleElement = this.styleElements.get(pluginId);
if (styleElement) {
styleElement.remove();
this.styleElements.delete(pluginId);
}
}
private disposePluginAPI(pluginId: string): void {
const dispose = this.apiDisposers.get(pluginId);
if (dispose) {
dispose();
this.apiDisposers.delete(pluginId);
}
}
private async startPluginPhase(pluginIds: string[]): Promise<void> {
const registeredIds = new Set(this.plugins.keys());
const idsToStart = pluginIds.filter((id) => registeredIds.has(id));
const startPromises = idsToStart.map((id) =>
this.startPlugin(id).catch((error) => {
console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error);
}),
);
await Promise.allSettled(startPromises);
}
/** /**
* Attempts to start all registered plugins in phased order. * Attempts to start all registered plugins.
* Errors during the start of individual plugins are caught and logged, * Errors during the start of individual plugins are caught and logged,
* allowing other plugins to attempt to start. * allowing other plugins to attempt to start.
* *
* @returns {Promise<void>} A promise that resolves when all plugins have attempted to start. * @returns {Promise<void>} A promise that resolves when all plugins have attempted to start.
* It uses `Promise.allSettled` to wait for all start operations.
*/ */
public async startAllPlugins(): Promise<void> { public async startAllPlugins(): Promise<void> {
for (const phase of PLUGIN_START_PHASES) { const startPromises = Array.from(this.plugins.keys()).map((id) =>
await this.startPluginPhase(phase); this.startPlugin(id).catch((error) => {
} console.error(`Failed to start plugin "${id}":`, error);
return Promise.reject(error); // Still reject to indicate failure for this specific plugin if needed by caller
const phasedIds = new Set(PLUGIN_START_PHASES.flat()); }),
const remainingIds = Array.from(this.plugins.keys()).filter(
(id) => !phasedIds.has(id),
); );
if (remainingIds.length > 0) {
await this.startPluginPhase(remainingIds); await Promise.allSettled(startPromises);
}
} }
/** /**
@@ -279,8 +225,12 @@ export class PluginManager {
* @returns {Promise<void>} A promise that resolves when the plugin has been stopped. * @returns {Promise<void>} A promise that resolves when the plugin has been stopped.
*/ */
public async stopPlugin(pluginId: string): Promise<void> { public async stopPlugin(pluginId: string): Promise<void> {
this.removePluginStyles(pluginId); // Remove plugin styles
this.disposePluginAPI(pluginId); const styleElement = this.styleElements.get(pluginId);
if (styleElement) {
styleElement.remove();
this.styleElements.delete(pluginId);
}
const cleanup = this.cleanupFunctions.get(pluginId); const cleanup = this.cleanupFunctions.get(pluginId);
if (cleanup) { if (cleanup) {
-1
View File
@@ -141,7 +141,6 @@ export interface PluginAPI<T extends PluginSettings, S = any> {
settings: SettingsAPI<T>; settings: SettingsAPI<T>;
storage: TypedStorageAPI<S>; storage: TypedStorageAPI<S>;
events: EventsAPI; events: EventsAPI;
dispose: () => void;
} }
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> { export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {
+2
View File
@@ -39,6 +39,8 @@ pluginManager.registerPlugin(enhancedNavigationPlugin);
pluginManager.registerPlugin(globalSearchPluginLazy); pluginManager.registerPlugin(globalSearchPluginLazy);
pluginManager.registerPlugin(gradeAnalyticsPluginLazy); pluginManager.registerPlugin(gradeAnalyticsPluginLazy);
export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> { export async function initializePlugins(): Promise<void> {
await pluginManager.startAllPlugins(); await pluginManager.startAllPlugins();
} }
+19 -34
View File
@@ -26,6 +26,7 @@ import {
updateEngageHomeMenuActive, updateEngageHomeMenuActive,
} 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 { loadAnalyticsPage } from "@/plugins/built-in/gradeAnalytics/loadAnalyticsPage";
import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue"; import { runStartupPopupQueue } from "@/seqta/utils/Openers/StartupPopupQueue";
import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes"; import { updateTimetableTimes } from "@/seqta/utils/updateTimetableTimes";
@@ -336,11 +337,7 @@ async function handleSublink(sublink: string | undefined): Promise<void> {
break; break;
case "analytics": case "analytics":
console.info("[BetterSEQTA+] Started Init (Analytics)"); console.info("[BetterSEQTA+] Started Init (Analytics)");
if (settingsState.onoff) { if (settingsState.onoff) void loadAnalyticsPage();
void import("@/plugins/built-in/gradeAnalytics/loadAnalyticsPage").then(
(m) => m.loadAnalyticsPage(),
);
}
finishLoad(); finishLoad();
break; break;
case undefined: case undefined:
@@ -491,37 +488,25 @@ async function handleReports(node: Element): Promise<void> {
} }
} }
function CheckNoticeTextColour(notice: Element) { function CheckNoticeTextColour(notice: any) {
const adjustNoticeColor = (node: Element) => { eventManager.register(
const hex = (node as HTMLElement).style.cssText.split(" ")[1]; "noticeAdded",
if (hex) { {
const hex1 = hex.slice(0, -1); elementType: "div",
const threshold = GetThresholdOfColor(hex1); className: "notice",
if (settingsState.DarkMode && threshold < 100) { parentElement: notice,
(node as HTMLElement).style.cssText = "--color: undefined;"; },
} (node) => {
} var hex = (node as HTMLElement).style.cssText.split(" ")[1];
}; if (hex) {
const hex1 = hex.slice(0, -1);
for (const node of notice.querySelectorAll("div.notice")) { var threshold = GetThresholdOfColor(hex1);
adjustNoticeColor(node); if (settingsState.DarkMode && threshold < 100) {
} (node as HTMLElement).style.cssText = "--color: undefined;";
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const added of mutation.addedNodes) {
if (!(added instanceof Element)) continue;
if (added.matches("div.notice")) {
adjustNoticeColor(added);
}
for (const node of added.querySelectorAll("div.notice")) {
adjustNoticeColor(node);
} }
} }
} },
}); );
observer.observe(notice, { childList: true, subtree: true });
} }
function watchForEngageLogin() { function watchForEngageLogin() {
-6
View File
@@ -1,6 +0,0 @@
export { init as Monofile } from "./monofile";
export async function initializePlugins(): Promise<void> {
const { pluginManager } = await import("./index");
await pluginManager.startAllPlugins();
}
+49 -98
View File
@@ -16,26 +16,6 @@ import { updateAllColors } from "./colors/Manager";
import { delay } from "@/seqta/utils/delay"; import { delay } from "@/seqta/utils/delay";
let cachedUserInfo: any = null; let cachedUserInfo: any = null;
let userInfoFetchPromise: Promise<any> | null = null;
let userInfoCacheListenersAttached = false;
export function invalidateCachedUserInfo(): void {
cachedUserInfo = null;
userInfoFetchPromise = null;
}
function attachUserInfoCacheInvalidation(): void {
if (userInfoCacheListenersAttached || typeof window === "undefined") return;
userInfoCacheListenersAttached = true;
window.addEventListener("pageshow", (event) => {
if (event.persisted) {
invalidateCachedUserInfo();
}
});
}
attachUserInfoCacheInvalidation();
let LightDarkModeSnakeEggButton = 0; let LightDarkModeSnakeEggButton = 0;
let sidebarAccessibilityObserver: MutationObserver | null = null; let sidebarAccessibilityObserver: MutationObserver | null = null;
@@ -45,66 +25,28 @@ let sidebarAccessibilityListenersAttached = false;
/** Marks menu rows that are off-screen in the drill stack (CSS blocks clicks). */ /** Marks menu rows that are off-screen in the drill stack (CSS blocks clicks). */
const BSPLUS_SIDEBAR_OFFSCREEN = "bsplus-sidebar-offscreen"; const BSPLUS_SIDEBAR_OFFSCREEN = "bsplus-sidebar-offscreen";
export async function getUserInfo(options?: { validateSession?: boolean }) { export async function getUserInfo() {
if (cachedUserInfo && !options?.validateSession) { if (cachedUserInfo) return cachedUserInfo;
try {
const response = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin,
}),
});
cachedUserInfo = (await response.json()).payload;
return cachedUserInfo; return cachedUserInfo;
} catch (error) {
console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error;
} }
if (userInfoFetchPromise && !options?.validateSession) {
return userInfoFetchPromise;
}
const fetchUserInfo = async () => {
try {
const response = await fetch(`${location.origin}/seqta/student/login`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
mode: "normal",
query: null,
redirect_url: location.origin,
}),
});
if (!response.ok) {
throw new Error(`Failed to get user info: HTTP ${response.status}`);
}
const payload = (await response.json()).payload;
if (
cachedUserInfo &&
options?.validateSession &&
payload?.id != null &&
cachedUserInfo.id != null &&
payload.id !== cachedUserInfo.id
) {
console.warn(
"[BetterSEQTA+] Session user changed; invalidating cached user info",
);
invalidateCachedUserInfo();
}
cachedUserInfo = payload;
return cachedUserInfo;
} catch (error) {
console.error("[BetterSEQTA+] Failed to get user info:", error);
throw error;
} finally {
if (!options?.validateSession) {
userInfoFetchPromise = null;
}
}
};
if (options?.validateSession) {
return fetchUserInfo();
}
userInfoFetchPromise = fetchUserInfo();
return userInfoFetchPromise;
} }
export async function AddBetterSEQTAElements() { export async function AddBetterSEQTAElements() {
@@ -134,7 +76,11 @@ export async function AddBetterSEQTAElements() {
menuList.insertBefore(fragment, menuList.firstChild); menuList.insertBefore(fragment, menuList.firstChild);
try { try {
await Promise.all([appendBackgroundToUI(), handleUserInfoAndStudentData()]); await Promise.all([
appendBackgroundToUI(),
handleUserInfo(),
handleStudentData(),
]);
} catch (error) { } catch (error) {
console.error("[BetterSEQTA+] Failed to initialize UI elements:", error); console.error("[BetterSEQTA+] Failed to initialize UI elements:", error);
} }
@@ -164,26 +110,11 @@ function createHomeButton(fragment: DocumentFragment, _: HTMLElement) {
); );
} }
async function handleUserInfoAndStudentData() { async function handleUserInfo() {
try { try {
const [userInfo, studentResponse] = await Promise.all([ updateUserInfo(await getUserInfo());
getUserInfo(),
fetch(`${location.origin}/seqta/student/load/message/people`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ mode: "student" }),
}),
]);
updateUserInfo(userInfo);
await updateStudentInfo((await studentResponse.json()).payload, userInfo);
} catch (error) { } catch (error) {
console.error( console.error("[BetterSEQTA+] Failed to handle user info:", error);
"[BetterSEQTA+] Failed to handle user info and student data:",
error,
);
} }
} }
@@ -239,7 +170,27 @@ function updateUserInfo(info: {
.appendChild(document.getElementsByClassName("logout")[0]); .appendChild(document.getElementsByClassName("logout")[0]);
} }
async function updateStudentInfo(students: any, info: Awaited<ReturnType<typeof getUserInfo>>) { async function handleStudentData() {
try {
const response = await fetch(
`${location.origin}/seqta/student/load/message/people`,
{
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({ mode: "student" }),
},
);
await updateStudentInfo((await response.json()).payload);
} catch (error) {
console.error("[BetterSEQTA+] Failed to handle student data:", error);
}
}
async function updateStudentInfo(students: any) {
const info = await getUserInfo();
const index = students.findIndex( const index = students.findIndex(
(person: any) => (person: any) =>
person.firstname == info.userDesc.split(" ")[0] && person.firstname == info.userDesc.split(" ")[0] &&
+1 -13
View File
@@ -21,7 +21,6 @@ export async function appendBackgroundToUI() {
} }
let lastLoadedId: string | null = null; let lastLoadedId: string | null = null;
let lastBlobUrl: string | null = null;
export async function loadBackground() { export async function loadBackground() {
if (!isIndexedDBSupported()) { if (!isIndexedDBSupported()) {
@@ -37,10 +36,6 @@ export async function loadBackground() {
backgroundContainer.remove(); backgroundContainer.remove();
} }
lastLoadedId = null; lastLoadedId = null;
if (lastBlobUrl) {
URL.revokeObjectURL(lastBlobUrl);
lastBlobUrl = null;
}
return; return;
} }
@@ -78,19 +73,12 @@ export async function loadBackground() {
mediaContainer.innerHTML = ""; mediaContainer.innerHTML = "";
if (lastBlobUrl) {
URL.revokeObjectURL(lastBlobUrl);
lastBlobUrl = null;
}
const mediaElement = const mediaElement =
background.type === "video" background.type === "video"
? document.createElement("video") ? document.createElement("video")
: document.createElement("img"); : document.createElement("img");
const blobUrl = URL.createObjectURL(background.blob); mediaElement.src = URL.createObjectURL(background.blob);
lastBlobUrl = blobUrl;
mediaElement.src = blobUrl;
mediaElement.classList.add("background"); mediaElement.classList.add("background");
if (mediaElement instanceof HTMLVideoElement) { if (mediaElement instanceof HTMLVideoElement) {
+8 -9
View File
@@ -1,27 +1,26 @@
import renderSvelte from "@/interface/main";
import Store from "@/interface/pages/store.svelte";
import { unmount } from "svelte"; import { unmount } from "svelte";
let remove: () => void; let remove: () => void;
export async function OpenStorePage(): Promise<void> { export function OpenStorePage() {
remove = await renderStore(); remove = renderStore();
} }
export async function renderStore() { export function renderStore() {
const [{ default: renderSvelte }, { default: Store }] = await Promise.all([
import("@/interface/main"),
import("@/interface/pages/store.svelte"),
]);
const container = document.querySelector("#container"); const container = document.querySelector("#container");
if (!container) { if (!container) {
throw new Error("Container not found"); throw new Error("Container not found");
} }
// Avoid stacking multiple store roots if opened repeatedly without close.
document.getElementById("store")?.remove(); document.getElementById("store")?.remove();
const child = document.createElement("div"); const child = document.createElement("div");
child.id = "store"; child.id = "store";
container.appendChild(child); container!.appendChild(child);
const shadow = child.attachShadow({ mode: "open" }); const shadow = child.attachShadow({ mode: "open" });
const app = renderSvelte(Store, shadow); const app = renderSvelte(Store, shadow);
+13 -30
View File
@@ -3,10 +3,11 @@ import {
closeExtensionPopup, closeExtensionPopup,
SettingsClicked, SettingsClicked,
} from "../Closers/closeExtensionPopup"; } from "../Closers/closeExtensionPopup";
import renderSvelte from "@/interface/main";
import { SettingsResizer } from "@/seqta/ui/SettingsResizer"; import { SettingsResizer } from "@/seqta/ui/SettingsResizer";
import Settings from "@/interface/pages/settings.svelte";
let isSettingsRendered = false; let isSettingsRendered = false;
let settingsLoadPromise: Promise<void> | null = null;
function extensionOutsideClickHandler(extensionPopup: HTMLElement) { function extensionOutsideClickHandler(extensionPopup: HTMLElement) {
return (event: MouseEvent) => { return (event: MouseEvent) => {
@@ -37,39 +38,21 @@ export function addExtensionSettings() {
(extensionContainer ?? document.body).addEventListener("click", handler, false); (extensionContainer ?? document.body).addEventListener("click", handler, false);
} }
async function loadSettingsUi(extensionPopup: HTMLElement): Promise<void> { export function renderSettingsIfNeeded() {
if (isSettingsRendered) return;
const [{ default: renderSvelte }, { default: Settings }] = await Promise.all([
import("@/interface/main"),
import("@/interface/pages/settings.svelte"),
]);
const shadow = extensionPopup.attachShadow({ mode: "open" });
const mount = () => renderSvelte(Settings, shadow);
if ("requestIdleCallback" in window) {
requestIdleCallback(mount);
} else {
mount();
}
isSettingsRendered = true;
}
export async function renderSettingsIfNeeded(): Promise<void> {
if (isSettingsRendered) return; if (isSettingsRendered) return;
const extensionPopup = document.getElementById("ExtensionPopup"); const extensionPopup = document.getElementById("ExtensionPopup");
if (!extensionPopup) return; if (!extensionPopup) return;
if (!settingsLoadPromise) { try {
settingsLoadPromise = loadSettingsUi(extensionPopup).catch((err) => { const shadow = extensionPopup.attachShadow({ mode: "open" });
settingsLoadPromise = null; if ('requestIdleCallback' in window) {
console.error(err); requestIdleCallback(() => renderSvelte(Settings, shadow));
throw err; } else {
}); renderSvelte(Settings, shadow);
}
isSettingsRendered = true;
} catch (err) {
console.error(err);
} }
await settingsLoadPromise;
} }
@@ -1,7 +1,7 @@
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import { animate } from "motion"; import { animate } from "motion";
import { settingsPopup } from "@/seqta/utils/settingsPopup"; import { settingsPopup } from "@/interface/hooks/SettingsPopup";
export let SettingsClicked = false; export let SettingsClicked = false;
+17 -15
View File
@@ -1,6 +1,7 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache"; import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync"; import { clearLastUploadedSnapshot } from "@/seqta/utils/cloudSettingsSync";
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
@@ -86,10 +87,11 @@ class CloudAuthService {
} }
/** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */ /** Pull cloud settings backup after a fresh sign-in (matches manual “Download from cloud”). */
private triggerCloudSettingsDownloadAfterLogin(): void { private triggerCloudSettingsDownloadAfterLogin(accessToken: string): void {
void browser.runtime void browser.runtime
.sendMessage({ .sendMessage({
type: "cloudSettingsDownload", type: "cloudSettingsDownload",
token: accessToken,
}) })
.then((res: unknown) => { .then((res: unknown) => {
const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined; const r = res as { success?: boolean; notFound?: boolean; error?: string } | undefined;
@@ -110,6 +112,7 @@ class CloudAuthService {
/** Persist an updated user object (e.g. after cloud profile picture sync). */ /** Persist an updated user object (e.g. after cloud profile picture sync). */
public async setUser(user: CloudUser | null): Promise<void> { public async setUser(user: CloudUser | null): Promise<void> {
(settingsState as any).setKey(STORAGE_KEYS.user, user);
await browser.storage.local.set({ [STORAGE_KEYS.user]: user }); await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
this._state = { this._state = {
isLoggedIn: this._state.isLoggedIn, isLoggedIn: this._state.isLoggedIn,
@@ -119,8 +122,11 @@ class CloudAuthService {
} }
private async getClientId(): Promise<string> { private async getClientId(): Promise<string> {
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId); let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined;
let clientId = stored[STORAGE_KEYS.clientId] as string | undefined; if (!clientId) {
const stored = await browser.storage.local.get(STORAGE_KEYS.clientId);
clientId = stored[STORAGE_KEYS.clientId] as string | undefined;
}
if (!clientId) { if (!clientId) {
const reserveResult = (await browser.runtime.sendMessage({ const reserveResult = (await browser.runtime.sendMessage({
type: "cloudReserveClient", type: "cloudReserveClient",
@@ -130,7 +136,7 @@ class CloudAuthService {
throw new Error(reserveResult?.error ?? "Failed to reserve client"); throw new Error(reserveResult?.error ?? "Failed to reserve client");
} }
clientId = reserveResult.client_id; clientId = reserveResult.client_id;
await browser.storage.local.set({ [STORAGE_KEYS.clientId]: clientId }); (settingsState as any).setKey(STORAGE_KEYS.clientId, clientId);
} }
return clientId; return clientId;
} }
@@ -174,17 +180,15 @@ class CloudAuthService {
error?: string; error?: string;
}; };
if (result?.access_token && result?.refresh_token) { if (result?.access_token && result?.refresh_token) {
await browser.storage.local.set({ (settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token);
[STORAGE_KEYS.accessToken]: result.access_token, (settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token);
[STORAGE_KEYS.refreshToken]: result.refresh_token, (settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null);
[STORAGE_KEYS.user]: result.user ?? null,
});
this._state = { this._state = {
isLoggedIn: true, isLoggedIn: true,
user: result.user ?? null, user: result.user ?? null,
}; };
this.notify(); this.notify();
this.triggerCloudSettingsDownloadAfterLogin(); this.triggerCloudSettingsDownloadAfterLogin(result.access_token);
return { success: true }; return { success: true };
} }
return { return {
@@ -235,11 +239,9 @@ class CloudAuthService {
}; };
if (refreshResult?.access_token && refreshResult?.refresh_token) { if (refreshResult?.access_token && refreshResult?.refresh_token) {
await browser.storage.local.set({ (settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token);
[STORAGE_KEYS.accessToken]: refreshResult.access_token, (settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token);
[STORAGE_KEYS.refreshToken]: refreshResult.refresh_token, (settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null);
[STORAGE_KEYS.user]: refreshResult.user ?? null,
});
this._state = { this._state = {
isLoggedIn: true, isLoggedIn: true,
user: refreshResult.user ?? null, user: refreshResult.user ?? null,
@@ -1,28 +1,13 @@
import stringToHTML from "../stringToHTML"; import stringToHTML from "../stringToHTML";
function isSafeShortcutHref(url: string): boolean {
if (typeof url !== "string" || !url.trim()) return false;
try {
const parsed = new URL(url, window.location.href);
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
} catch {
return false;
}
}
export function CreateCustomShortcutDiv(element: any) { export function CreateCustomShortcutDiv(element: any) {
// Creates the stucture and element information for each seperate shortcut // Creates the stucture and element information for each seperate shortcut
const container = document.getElementById("shortcuts"); const container = document.getElementById("shortcuts");
if (!container) return; if (!container) return;
var shortcut = document.createElement("a"); var shortcut = document.createElement("a");
if (isSafeShortcutHref(element.url)) { shortcut.setAttribute("href", element.url);
shortcut.setAttribute("href", element.url); shortcut.setAttribute("target", "_blank");
shortcut.setAttribute("target", "_blank");
} else {
shortcut.setAttribute("href", "#");
shortcut.setAttribute("aria-disabled", "true");
}
var shortcutdiv = document.createElement("div"); var shortcutdiv = document.createElement("div");
shortcutdiv.classList.add("shortcut"); shortcutdiv.classList.add("shortcut");
shortcutdiv.classList.add("customshortcut"); shortcutdiv.classList.add("customshortcut");
-1
View File
@@ -52,7 +52,6 @@ export function OpenAboutPage() {
</a> </a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg> <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24"> <svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /> <path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -61,12 +61,11 @@ export function showBsCloudAutoSyncAnnouncement(onDismissed?: () => void) {
</div> </div>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
settingsState.bsCloudAutoSyncAnnouncementShown = true;
openPopup({ openPopup({
header, header,
content: [imageContainer, text], content: [imageContainer, text],
afterClose: () => { afterClose: onDismissed,
settingsState.bsCloudAutoSyncAnnouncementShown = true;
onDismissed?.();
},
}); });
} }
+9 -25
View File
@@ -5,18 +5,7 @@ import Sortable from "sortablejs";
export let MenuOptionsOpen = false; export let MenuOptionsOpen = false;
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function OpenMenuOptions() { export function OpenMenuOptions() {
if (MenuOptionsOpen) return;
var container = document.getElementById("container"); var container = document.getElementById("container");
var menu = document.getElementById("menu"); var menu = document.getElementById("menu");
@@ -34,9 +23,9 @@ export function OpenMenuOptions() {
for (let i = 0; i < childnodes.length; i++) { for (let i = 0; i < childnodes.length; i++) {
const element = childnodes[i]; const element = childnodes[i];
if ( if (
settingsState.defaultmenuorder.indexOf( !settingsState.defaultmenuorder.indexOf(
(element as HTMLElement).dataset.key, (element as HTMLElement).dataset.key,
) === -1 )
) { ) {
let newdefaultmenuorder = settingsState.defaultmenuorder; let newdefaultmenuorder = settingsState.defaultmenuorder;
newdefaultmenuorder.push((element as HTMLElement).dataset.key); newdefaultmenuorder.push((element as HTMLElement).dataset.key);
@@ -64,7 +53,7 @@ export function OpenMenuOptions() {
var savebutton = document.createElement("div"); var savebutton = document.createElement("div");
savebutton.classList.add("editmenuoption"); savebutton.classList.add("editmenuoption");
savebutton.innerText = "Save"; savebutton.innerText = "Save";
savebutton.id = "savemenuoption"; savebutton.id = "restoredefaultoption";
menusettings.appendChild(defaultbutton); menusettings.appendChild(defaultbutton);
menusettings.appendChild(savebutton); menusettings.appendChild(savebutton);
@@ -82,18 +71,15 @@ export function OpenMenuOptions() {
(element.firstChild as HTMLElement).classList.remove("noscroll"); (element.firstChild as HTMLElement).classList.remove("noscroll");
} }
const menuKey = escapeHtmlAttr((element as HTMLElement).dataset.key ?? "");
let MenuItemToggle = stringToHTML( let MenuItemToggle = stringToHTML(
`<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${menuKey}"><label for="${menuKey}" class="onoffswitch-label"></label>`, `<div class="onoffswitch" style="margin: auto 0;"><input class="onoffswitch-checkbox notification menuitem" type="checkbox" id="${(element as HTMLElement).dataset.key}"><label for="${(element as HTMLElement).dataset.key}" class="onoffswitch-label"></label>`,
).firstChild; ).firstChild;
(element as HTMLElement).append(MenuItemToggle!); (element as HTMLElement).append(MenuItemToggle!);
if (!element.dataset.betterseqta) { if (!element.dataset.betterseqta) {
const a = document.createElement("section"); const a = document.createElement("section");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element); cloneAttributes(a, element);
while (element.firstChild) {
a.appendChild(element.firstChild);
}
menu!.firstChild!.insertBefore(a, element); menu!.firstChild!.insertBefore(a, element);
element.remove(); element.remove();
} }
@@ -123,12 +109,12 @@ export function OpenMenuOptions() {
} else { } else {
(buttons[i] as HTMLInputElement).checked = true; (buttons[i] as HTMLInputElement).checked = true;
} }
(buttons[i] as HTMLInputElement).checked = true;
} }
let sortable: Sortable | undefined;
try { try {
var el = document.querySelector("#menu > ul"); var el = document.querySelector("#menu > ul");
sortable = Sortable.create(el as HTMLElement, { var sortable = Sortable.create(el as HTMLElement, {
draggable: ".draggable", draggable: ".draggable",
dataIdAttr: "data-key", dataIdAttr: "data-key",
animation: 150, animation: 150,
@@ -192,10 +178,8 @@ export function OpenMenuOptions() {
if (!element.dataset.betterseqta) { if (!element.dataset.betterseqta) {
const a = document.createElement("li"); const a = document.createElement("li");
a.innerHTML = element.innerHTML;
cloneAttributes(a, element); cloneAttributes(a, element);
while (element.firstChild) {
a.appendChild(element.firstChild);
}
menu!.firstChild!.insertBefore(a, element); menu!.firstChild!.insertBefore(a, element);
element.remove(); element.remove();
} }
@@ -225,7 +209,7 @@ export function OpenMenuOptions() {
"important", "important",
); );
} }
if (sortable) saveNewOrder(sortable); saveNewOrder(sortable);
}); });
} }
@@ -105,7 +105,6 @@ export function OpenMinecraftServerPopup() {
</a> </a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg> <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24"> <svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /> <path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -62,13 +62,12 @@ export function showPrivacyNotification(onDismissed?: () => void) {
attachPopupMediaFullscreenIfPresent(text, "img.aboutImg"); attachPopupMediaFullscreenIfPresent(text, "img.aboutImg");
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
openPopup({ openPopup({
header, header,
content: [text], content: [text],
afterClose: () => { afterClose: onDismissed,
settingsState.privacyStatementLastUpdated = "2025-12-20";
settingsState.privacyStatementShown = true;
onDismissed?.();
},
}); });
} }
@@ -31,12 +31,12 @@ export function OpenPrivacyStatement() {
<p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p> <p>BetterSEQTA+ uses your browser's local storage to save your preferences and settings. This data remains on your device and is never transmitted anywhere. You can clear this data at any time through your browser's settings.</p>
<h3>Open Source</h3> <h3>Open Source</h3>
<p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p> <p>BetterSEQTA+ is an open-source project. You can review our code on <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub</a> to verify our privacy practices. We believe in transparency and encourage you to inspect the code yourself.</p>
<h3>Our Commitment</h3> <h3>Our Commitment</h3>
<p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p> <p>We are committed to providing the best features possible while respecting your privacy. We understand that schools and students have concerns about data privacy, and we want to assure you that BetterSEQTA+ is designed with privacy as a core principle.</p>
<p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p> <p style="margin-top: 20px; font-weight: bold;">If you have any questions or concerns about our privacy practices, please reach out to us through our <a href="https://github.com/BetterSEQTA/BetterSEQTA-Plus" target="_blank" style="color: inherit; text-decoration: underline;">GitHub repository</a>.</p>
</div> </div>
`).firstChild as HTMLElement; `).firstChild as HTMLElement;
@@ -4,13 +4,13 @@ import { settingsState } from "../listeners/SettingsState";
import { closePopup } from "./PopupManager"; import { closePopup } from "./PopupManager";
import { getApiBase } from "../DevApiBase"; import { getApiBase } from "../DevApiBase";
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
import { cloudAuth } from "../CloudAuth";
import type { Theme } from "@/interface/types/Theme"; import type { Theme } from "@/interface/types/Theme";
import { import {
buildModalHeroSlides, buildModalHeroSlides,
normalizeStoreTheme, normalizeStoreTheme,
} from "@/interface/utils/themeStoreFlavours"; } from "@/interface/utils/themeStoreFlavours";
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen"; import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
export interface ThemeOfTheMonthEntry { export interface ThemeOfTheMonthEntry {
id: string; id: string;
@@ -67,15 +67,17 @@ function heroUrlFromStoreTheme(theme: {
coverImage?: string | null; coverImage?: string | null;
}): string | null { }): string | null {
const url = (theme.marqueeImage || theme.coverImage || "").trim(); const url = (theme.marqueeImage || theme.coverImage || "").trim();
return allowedPopupImageUrl(url); return url || null;
} }
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> { export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
try { try {
const res = (await browser.runtime.sendMessage({ const token = await cloudAuth.getStoredToken();
type: "fetchThemeDetails", const res = (await browser.runtime.sendMessage({
themeId, type: "fetchThemeDetails",
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } }; themeId,
token: token ?? undefined,
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
if (!res?.success || !res?.data?.theme) return null; if (!res?.success || !res?.data?.theme) return null;
return normalizeStoreTheme(res.data.theme); return normalizeStoreTheme(res.data.theme);
@@ -98,12 +100,7 @@ function buildPopupGallerySlides(
heroUrl: string | null, heroUrl: string | null,
): PopupGallerySlide[] { ): PopupGallerySlide[] {
if (storeTheme) { if (storeTheme) {
return buildModalHeroSlides(storeTheme) return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
.map((s) => {
const imageUrl = allowedPopupImageUrl(s.imageUrl);
return imageUrl ? { imageUrl, caption: s.caption } : null;
})
.filter((s): s is PopupGallerySlide => s !== null);
} }
if (heroUrl) { if (heroUrl) {
return [{ imageUrl: heroUrl, caption: entry.title }]; return [{ imageUrl: heroUrl, caption: entry.title }];
@@ -645,7 +642,7 @@ export async function OpenThemeOfTheMonthPopup(
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null; const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
const heroUrl = const heroUrl =
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ?? (storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
allowedPopupImageUrl(entry.cover_image) ?? entry.cover_image?.trim() ??
null; null;
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl); const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0; const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
@@ -785,7 +782,7 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => { card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month; settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismissWithCleanup(); dismissWithCleanup();
void openThemeStoreWithHighlight(linkedThemeId!); openThemeStoreWithHighlight(linkedThemeId!);
}); });
const openDontShowConfirm = () => { const openDontShowConfirm = () => {
+8 -2
View File
@@ -42,6 +42,13 @@ 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.7.2 Analytics Fixes</h1>
<li>Fixed analytics page breaking on certain configurations.</li>
<li>Added safer DOM handling for analytics to prevent crashes.</li>
<li>Fixed hovering tooltip for forecast analytics chart.</li>
<li>Improved grade analytics layout, controls, and forecast chart rendering.</li>
<li>Updated update image for new layout.</li>
<h1>3.7.0 Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements</h1> <h1>3.7.0 Grade Analytics, Enhanced Navigation, fonts, Global Search & SEQTA Engage Improvements</h1>
<li>Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.</li> <li>Added Enhanced Navigation for courses: the navigator now auto-scrolls to the selected lesson (e.g. inside the "Go to…" popup) and prev/next arrows for jumping between lessons.</li>
<li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li> <li>Added Grade Analytics, new sidebar page with grade trend charts synced from SEQTA.</li>
@@ -396,7 +403,6 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a> </a>
<a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://www.youtube.com/@BetterSEQTAPlus" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg> <svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="50px" height="50px"><path d="M 44.898438 14.5 C 44.5 12.300781 42.601563 10.699219 40.398438 10.199219 C 37.101563 9.5 31 9 24.398438 9 C 17.800781 9 11.601563 9.5 8.300781 10.199219 C 6.101563 10.699219 4.199219 12.199219 3.800781 14.5 C 3.398438 17 3 20.5 3 25 C 3 29.5 3.398438 33 3.898438 35.5 C 4.300781 37.699219 6.199219 39.300781 8.398438 39.800781 C 11.898438 40.5 17.898438 41 24.5 41 C 31.101563 41 37.101563 40.5 40.601563 39.800781 C 42.800781 39.300781 44.699219 37.800781 45.101563 35.5 C 45.5 33 46 29.398438 46.101563 25 C 45.898438 20.5 45.398438 17 44.898438 14.5 Z M 19 32 L 19 18 L 31.199219 25 Z"/></svg>
</a>
<a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;"> <a class="socials" href="https://chromewebstore.google.com/detail/betterseqta+/afdgaoaclhkhemfkkkonemoapeinchel" style="background: none !important; margin: 0 5px; padding: 0; display: flex; align-items: center;">
<svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24"> <svg style="width:25px; height:25px; vertical-align: middle;" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /> <path fill="currentColor" d="M12,20L15.46,14H15.45C15.79,13.4 16,12.73 16,12C16,10.8 15.46,9.73 14.62,9H19.41C19.79,9.93 20,10.94 20,12A8,8 0 0,1 12,20M4,12C4,10.54 4.39,9.18 5.07,8L8.54,14H8.55C9.24,15.19 10.5,16 12,16C12.45,16 12.88,15.91 13.29,15.77L10.89,19.91C7,19.37 4,16.04 4,12M15,12A3,3 0 0,1 12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12M12,4C14.96,4 17.54,5.61 18.92,8H12C10.06,8 8.45,9.38 8.08,11.21L5.7,7.08C7.16,5.21 9.44,4 12,4M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
@@ -404,7 +410,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
</a> </a>
</div> </div>
<div> <div>
<a href="https://ko-fi.com/sethburkart" target="_blank" rel="noopener noreferrer" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;"> <a href="https://ko-fi.com/sethburkart" target="_blank" style="background: none !important; margin:0;margin-left:6px;padding:0; display: flex; align-items: center;">
<img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" /> <img height="25" style="border:0px; height:25px; margin-right: -6px;" src="${kofi}" border="0" alt="Buy Me a Coffee at ko-fi.com" />
</a> </a>
</div> </div>
+2 -18
View File
@@ -57,15 +57,6 @@ interface OpenPopupOptions {
containerClass?: string; containerClass?: string;
} }
function chainAfterClose(next?: () => void) {
if (!next) return;
const previous = pendingAfterClose;
pendingAfterClose = () => {
next();
previous?.();
};
}
export function openPopup({ export function openPopup({
header, header,
content = [], content = [],
@@ -74,12 +65,7 @@ export function openPopup({
clearJustUpdated = false, clearJustUpdated = false,
containerClass, containerClass,
}: OpenPopupOptions = {}) { }: OpenPopupOptions = {}) {
if (document.getElementById("whatsnewbk")) { pendingAfterClose = afterClose;
chainAfterClose(afterClose);
return;
}
chainAfterClose(afterClose);
const background = document.createElement("div"); const background = document.createElement("div");
background.id = "whatsnewbk"; background.id = "whatsnewbk";
@@ -101,9 +87,7 @@ export function openPopup({
container.append(closeButton); container.append(closeButton);
background.append(container); background.append(container);
const appContainer = document.getElementById("container"); document.getElementById("container")!.append(background);
if (!appContainer) return;
appContainer.append(background);
if (settingsState.animations) { if (settingsState.animations) {
(motionAnimate as any)( (motionAnimate as any)(
@@ -1,16 +0,0 @@
/**
* Minimal allowlist for remote popup/API image (and media) URLs.
* Only https: URLs are accepted; everything else is rejected.
*/
export function allowedPopupImageUrl(url: string | null | undefined): string | null {
if (!url) return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== "https:") return null;
return parsed.href;
} catch {
return null;
}
}
@@ -5,7 +5,6 @@
*/ */
import { settingsState } from "../listeners/SettingsState"; import { settingsState } from "../listeners/SettingsState";
import { allowedPopupImageUrl } from "./allowedPopupImageUrl";
const FULLSCREENABLE_CLASS = "popup-media-fullscreenable"; const FULLSCREENABLE_CLASS = "popup-media-fullscreenable";
const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible"; const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible";
@@ -57,22 +56,13 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.loop = v.loop; nv.loop = v.loop;
nv.muted = v.muted; nv.muted = v.muted;
nv.volume = v.volume; nv.volume = v.volume;
let hasValidSource = false;
for (const s of v.querySelectorAll("source")) { for (const s of v.querySelectorAll("source")) {
const src = allowedPopupImageUrl((s as HTMLSourceElement).src);
if (!src) continue;
hasValidSource = true;
const ns = document.createElement("source"); const ns = document.createElement("source");
ns.src = src; ns.src = (s as HTMLSourceElement).src;
const t = (s as HTMLSourceElement).type; const t = (s as HTMLSourceElement).type;
if (t) ns.type = t; if (t) ns.type = t;
nv.appendChild(ns); nv.appendChild(ns);
} }
if (!hasValidSource) {
const directSrc = allowedPopupImageUrl(v.currentSrc || v.src);
if (!directSrc) return;
nv.src = directSrc;
}
nv.addEventListener( nv.addEventListener(
"loadeddata", "loadeddata",
() => { () => {
@@ -89,12 +79,9 @@ function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) {
nv.load(); nv.load();
media = nv; media = nv;
} else { } else {
const rawSrc = source.currentSrc || source.src;
const safeSrc = allowedPopupImageUrl(rawSrc);
if (!safeSrc) return;
const img = document.createElement("img"); const img = document.createElement("img");
img.classList.add("bsplus-popup-media-overlay-media"); img.classList.add("bsplus-popup-media-overlay-media");
img.src = safeSrc; img.src = source.currentSrc || source.src;
img.alt = source.alt || ""; img.alt = source.alt || "";
media = img; media = img;
} }
+10 -20
View File
@@ -22,16 +22,6 @@ class ReactFiber {
return new ReactFiber(selector, options); return new ReactFiber(selector, options);
} }
private getTargetOrigin(): string {
return window.location.origin;
}
private isTrustedMessage(event: MessageEvent): boolean {
return (
event.source === window && event.origin === this.getTargetOrigin()
);
}
private async sendMessage(action: string, payload: any = {}): Promise<any> { private async sendMessage(action: string, payload: any = {}): Promise<any> {
return new Promise((resolve, _) => { return new Promise((resolve, _) => {
const messageId = this.messageIdCounter++; const messageId = this.messageIdCounter++;
@@ -44,8 +34,7 @@ class ReactFiber {
messageId, messageId,
}; };
const listener = (response: MessageEvent) => { const listener = (response: any) => {
if (!this.isTrustedMessage(response)) return;
if ( if (
response.data?.type === "reactFiberResponse" && response.data?.type === "reactFiberResponse" &&
response.data?.messageId === messageId response.data?.messageId === messageId
@@ -58,7 +47,7 @@ class ReactFiber {
} }
}; };
window.addEventListener("message", listener); window.addEventListener("message", listener);
window.postMessage(message, this.getTargetOrigin()); window.postMessage(message, "*");
}); });
} }
@@ -68,14 +57,15 @@ class ReactFiber {
}); });
} }
async setState(update: Record<string, unknown>): Promise<ReactFiber> { async setState(update: any | ((prevState: any) => any)): Promise<ReactFiber> {
if (typeof update !== "object" || update === null || Array.isArray(update)) { const updateFnString =
throw new TypeError( typeof update === "function" ? update.toString() : null;
"ReactFiber.setState only accepts plain JSON-serializable objects", const updateObject = typeof update !== "function" ? update : null;
);
}
await this.sendMessage("setState", { updateObject: update }); await this.sendMessage("setState", {
updateFn: updateFnString,
updateObject,
});
return this; return this;
} }
+1 -1
View File
@@ -100,7 +100,7 @@ export async function SendNewsPage() {
? article.description ? article.description
: "No description available."; : "No description available.";
description.textContent = description.innerHTML =
articleDescription.length > 400 articleDescription.length > 400
? articleDescription.substring(0, 400) + "..." ? articleDescription.substring(0, 400) + "..."
: articleDescription; : articleDescription;
-50
View File
@@ -1,50 +0,0 @@
const ALLOWED_HOST_SUFFIXES = [
"betterseqta.org",
"accounts.betterseqta.org",
"raw.githubusercontent.com",
"github.com",
] as const;
function isPrivateOrLocalHost(hostname: string): boolean {
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (host === "127.0.0.1" || host.startsWith("127.")) return true;
if (host === "::1" || host === "0.0.0.0") return true;
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a === 127 || a === 0) return true;
}
if (host.includes(":")) {
const h = host.split("%")[0];
if (h.startsWith("fe80") || h.startsWith("fc") || h.startsWith("fd")) return true;
}
return false;
}
function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return ALLOWED_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
);
}
/** HTTPS-only fetch allowlist for background `fetchFromUrl`. */
export function isAllowedFetchUrl(urlString: string): boolean {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
if (isPrivateOrLocalHost(parsed.hostname)) return false;
return isAllowedHost(parsed.hostname);
}
+1 -13
View File
@@ -73,23 +73,14 @@ const OMIT_FROM_UPLOAD_EXACT = new Set<string>([
...KEYS_OMITTED_FROM_CLOUD_UPLOAD, ...KEYS_OMITTED_FROM_CLOUD_UPLOAD,
...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT, ...SENSITIVE_DEVICE_STORAGE_KEYS_EXACT,
...CLIENT_ONLY_CLOUD_KEYS_EXACT, ...CLIENT_ONLY_CLOUD_KEYS_EXACT,
"devMode",
"devGhReleaseVersionOverride",
]); ]);
const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function isUnsafeStorageKey(key: string): boolean {
return UNSAFE_STORAGE_KEYS.has(key);
}
/** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */ /** True if a storage key is part of the upload payload (and should trigger auto-upload when changed). */
export function isKeyIncludedInCloudUploadPayload(key: string): boolean { export function isKeyIncludedInCloudUploadPayload(key: string): boolean {
return !shouldOmitKeyFromCloudPayload(key); return !shouldOmitKeyFromCloudPayload(key);
} }
function shouldOmitKeyFromCloudPayload(key: string): boolean { function shouldOmitKeyFromCloudPayload(key: string): boolean {
if (isUnsafeStorageKey(key)) return true;
if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true; if (OMIT_FROM_UPLOAD_EXACT.has(key)) return true;
for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) { for (const prefix of SENSITIVE_DEVICE_STORAGE_KEY_PREFIXES) {
if (key.startsWith(prefix)) return true; if (key.startsWith(prefix)) return true;
@@ -124,7 +115,6 @@ function collectLocalKeysToPreserve(local: Record<string, unknown>): Record<stri
function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> { function stripExcludedKeysFromRemoteData(remote: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(remote)) { for (const [k, v] of Object.entries(remote)) {
if (isUnsafeStorageKey(k)) continue;
if (shouldOmitKeyFromCloudPayload(k)) continue; if (shouldOmitKeyFromCloudPayload(k)) continue;
out[k] = v; out[k] = v;
} }
@@ -346,7 +336,5 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
const migrated = migrateLegacyToPluginSettings(remoteFlat); const migrated = migrateLegacyToPluginSettings(remoteFlat);
const remoteSanitized = stripExcludedKeysFromRemoteData(migrated); const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
const local = (await browser.storage.local.get()) as Record<string, unknown>; await browser.storage.local.set(remoteSanitized);
const preserve = collectLocalKeysToPreserve(local);
await browser.storage.local.set({ ...remoteSanitized, ...preserve });
} }
@@ -37,8 +37,6 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [
"profile_picture_revision", "profile_picture_revision",
] as const; ] as const;
let defaultsEnsured = false;
/** /**
* Flat default map in upload shape (plugin-format only; no legacy keys). * Flat default map in upload shape (plugin-format only; no legacy keys).
*/ */
@@ -78,8 +76,6 @@ function mergePluginSettingsDefaults(
* Never overwrites existing values. Missing plugin settings respect legacy keys. * Never overwrites existing values. Missing plugin settings respect legacy keys.
*/ */
export async function ensureSyncableStorageDefaults(): Promise<void> { export async function ensureSyncableStorageDefaults(): Promise<void> {
if (defaultsEnsured) return;
const existing = await browser.storage.local.get(); const existing = await browser.storage.local.get();
const migratedFromExisting = migrateLegacyToPluginSettings({ const migratedFromExisting = migrateLegacyToPluginSettings({
...existing, ...existing,
@@ -105,6 +101,4 @@ export async function ensureSyncableStorageDefaults(): Promise<void> {
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await browser.storage.local.set(patch); await browser.storage.local.set(patch);
} }
defaultsEnsured = true;
} }
@@ -22,8 +22,6 @@ const handleNotificationClick = async (target: HTMLElement) => {
(item: any) => item.notificationID === parseInt(buttonId), (item: any) => item.notificationID === parseInt(buttonId),
); );
if (!matchingNotification?.message?.messageID) return;
await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20); await waitForElm('[class*="Viewer__Viewer___"] > div', true, 20);
// Select the specific direct message // Select the specific direct message
+2 -29
View File
@@ -3,7 +3,6 @@ interface EventListenerOptions {
textContent?: string; textContent?: string;
className?: string; className?: string;
id?: string; id?: string;
selector?: string;
customCheck?: (element: Element) => boolean; customCheck?: (element: Element) => boolean;
once?: boolean; once?: boolean;
parentElement?: Element; parentElement?: Element;
@@ -21,7 +20,6 @@ class EventManager {
private listeners: Map<string, EventListener[]> = new Map(); private listeners: Map<string, EventListener[]> = new Map();
private mutationObservers: Map<Element, MutationObserver> = new Map(); private mutationObservers: Map<Element, MutationObserver> = new Map();
private pendingElements: Set<Element> = new Set(); private pendingElements: Set<Element> = new Set();
private firedOnceIds: Set<string> = new Set();
private throttleTimeout: number = 5; // 5ms throttle private throttleTimeout: number = 5; // 5ms throttle
private throttleTimer: number | undefined; private throttleTimer: number | undefined;
private chunkSize: number = 50; // Process 50 elements per chunk private chunkSize: number = 50; // Process 50 elements per chunk
@@ -60,7 +58,6 @@ class EventManager {
} }
private buildSelector(options: EventListenerOptions): string | null { private buildSelector(options: EventListenerOptions): string | null {
if (options.selector) return options.selector;
if (options.textContent || options.customCheck) return null; if (options.textContent || options.customCheck) return null;
let selector = options.elementType || ""; let selector = options.elementType || "";
@@ -74,23 +71,6 @@ class EventManager {
return selector.trim() || null; return selector.trim() || null;
} }
private getElementsToCheck(
element: Element,
options: EventListenerOptions,
): Element[] {
const selector = this.buildSelector(options);
if (!selector) return [element];
const targets = new Set<Element>();
if (element.matches(selector)) {
targets.add(element);
}
for (const match of element.querySelectorAll(selector)) {
targets.add(match);
}
return Array.from(targets);
}
private async scanExistingElements( private async scanExistingElements(
options: EventListenerOptions, options: EventListenerOptions,
callback: (element: Element) => void, callback: (element: Element) => void,
@@ -194,17 +174,10 @@ class EventManager {
private async checkElement(element: Element): Promise<void> { private async checkElement(element: Element): Promise<void> {
for (const [event, listeners] of this.listeners.entries()) { for (const [event, listeners] of this.listeners.entries()) {
for (const { id, options, callback } of listeners) { for (const { id, options, callback } of listeners) {
if (options.once && this.firedOnceIds.has(id)) continue; if (this.matchesOptions(element, options)) {
callback(element);
const targets = this.getElementsToCheck(element, options);
for (const target of targets) {
if (!this.matchesOptions(target, options)) continue;
callback(target);
if (options.once) { if (options.once) {
this.firedOnceIds.add(id);
this.unregisterById(event, id); this.unregisterById(event, id);
break;
} }
} }
} }
+34 -63
View File
@@ -6,20 +6,15 @@ import {
OpenMenuOptions, OpenMenuOptions,
} from "@/seqta/utils/Openers/OpenMenuOptions"; } from "@/seqta/utils/Openers/OpenMenuOptions";
import { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
import {
CloseThemeCreator,
OpenThemeCreator,
} from "@/plugins/built-in/themes/ThemeCreator";
import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate"; import sendThemeUpdate from "@/seqta/utils/sendThemeUpdate";
import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent"; import hideSensitiveContent from "@/seqta/ui/dev/hideSensitiveContent";
import type { ThemeManager } from "@/plugins/built-in/themes/theme-manager";
let themeManagerPromise: Promise<ThemeManager> | null = null; const themeManager = ThemeManager.getInstance();
function getThemeManager(): Promise<ThemeManager> {
if (!themeManagerPromise) {
themeManagerPromise = import("@/plugins/built-in/themes/theme-manager").then(
({ ThemeManager }) => ThemeManager.getInstance(),
);
}
return themeManagerPromise;
}
export class MessageHandler { export class MessageHandler {
constructor() { constructor() {
@@ -39,7 +34,6 @@ export class MessageHandler {
case "UpdateThemePreview": case "UpdateThemePreview":
if (request?.save == true) { if (request?.save == true) {
const save = async () => { const save = async () => {
const themeManager = await getThemeManager();
await themeManager.saveTheme({ await themeManager.saveTheme({
...request.body, ...request.body,
userEdited: true, userEdited: true,
@@ -50,88 +44,65 @@ export class MessageHandler {
sendResponse({ status: "success" }); sendResponse({ status: "success" });
sendThemeUpdate(); sendThemeUpdate();
}; };
void save(); save();
} else { } else {
void getThemeManager().then((themeManager) => { themeManager.updatePreview(request.body);
themeManager.updatePreview(request.body); sendResponse({ status: "success" });
sendResponse({ status: "success" });
});
} }
return true; return true;
case "GetTheme": case "GetTheme":
void getThemeManager().then((themeManager) => { themeManager.getTheme(request.body.themeID).then((theme) => {
themeManager.getTheme(request.body.themeID).then((theme) => { sendResponse(theme);
sendResponse(theme);
});
}); });
return true; return true;
case "SetTheme": case "SetTheme":
void getThemeManager().then((themeManager) => { themeManager.setTheme(request.body.themeID).then(() => {
themeManager.setTheme(request.body.themeID).then(() => { sendResponse({ status: "success" });
sendResponse({ status: "success" });
});
}); });
return true; break;
case "DisableTheme": case "DisableTheme":
void getThemeManager().then((themeManager) => { themeManager.disableTheme().then(() => {
themeManager.disableTheme().then(() => { sendResponse({ status: "success" });
sendResponse({ status: "success" });
});
}); });
return true; break;
case "DeleteTheme": case "DeleteTheme":
void getThemeManager().then((themeManager) => { themeManager.deleteTheme(request.body.themeID).then(() => {
themeManager.deleteTheme(request.body.themeID).then(() => { sendResponse({ status: "success" });
sendResponse({ status: "success" });
});
}); });
return true; break;
case "ListThemes": case "ListThemes":
void getThemeManager().then((themeManager) => { themeManager.getAvailableThemes().then((themes) => {
themeManager.getAvailableThemes().then((themes) => { sendResponse(themes);
sendResponse(themes);
});
}); });
return true; return true;
case "OpenThemeCreator": { case "OpenThemeCreator":
const themeID = request?.body?.themeID; const themeID = request?.body?.themeID;
void import("@/plugins/built-in/themes/ThemeCreator").then( OpenThemeCreator(themeID ? themeID : "");
({ OpenThemeCreator }) => {
void OpenThemeCreator(themeID ? themeID : "");
},
);
closeExtensionPopup(); closeExtensionPopup();
sendResponse({ status: "success" }); sendResponse({ status: "success" });
break; break;
}
case "ShareTheme": case "ShareTheme":
void getThemeManager().then((themeManager) => { themeManager.shareTheme(request.body.themeID).then((id) => {
themeManager.shareTheme(request.body.themeID).then((id) => { sendResponse({ status: "success", id });
sendResponse({ status: "success", id });
});
}); });
return true; return true;
case "CloseThemeCreator": case "CloseThemeCreator":
void import("@/plugins/built-in/themes/ThemeCreator").then( try {
({ CloseThemeCreator }) => { CloseThemeCreator();
try { } catch (error) {
CloseThemeCreator(); console.error("Error closing theme creator:", error);
sendResponse({ status: "success" }); sendResponse({ status: "error" });
} catch (error) { }
console.error("Error closing theme creator:", error); sendResponse({ status: "success" });
sendResponse({ status: "error" }); break;
}
},
);
return true;
case "HideSensitive": case "HideSensitive":
hideSensitiveContent(); hideSensitiveContent();
+10 -87
View File
@@ -2,37 +2,6 @@ import browser from "webextension-polyfill";
import type { SettingsState } from "@/types/storage"; import type { SettingsState } from "@/types/storage";
import type { Subscriber, Unsubscriber } from "svelte/store"; import type { Subscriber, Unsubscriber } from "svelte/store";
/** Auth/session keys live in `chrome.storage.local` only — not on the settingsState proxy. */
const EXCLUDED_FROM_SETTINGS_SURFACE = new Set([
"bsplus_token",
"bsplus_refresh_token",
"bsplus_client_id",
"bsplus_user",
"cloudAccessToken",
"cloudUsername",
]);
function isExcludedSettingsKey(key: string): boolean {
return EXCLUDED_FROM_SETTINGS_SURFACE.has(key);
}
const SAVE_DEBOUNCE_MS = 200;
function storageChangeIsNoop(oldValue: unknown, newValue: unknown): boolean {
if (oldValue === newValue) return true;
if (
oldValue === undefined ||
newValue === undefined ||
typeof oldValue !== "object" ||
typeof newValue !== "object" ||
oldValue === null ||
newValue === null
) {
return false;
}
return JSON.stringify(oldValue) === JSON.stringify(newValue);
}
type ChangeListener = (newValue: any, oldValue: any) => void; type ChangeListener = (newValue: any, oldValue: any) => void;
type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void; type GlobalChangeListener = (newValue: any, oldValue: any, key: string) => void;
@@ -42,11 +11,9 @@ class StorageManager {
private listeners: Map<string, Set<ChangeListener>>; private listeners: Map<string, Set<ChangeListener>>;
private globalListeners: Set<GlobalChangeListener>; private globalListeners: Set<GlobalChangeListener>;
private subscribers: Set<Subscriber<SettingsState>> = new Set(); private subscribers: Set<Subscriber<SettingsState>> = new Set();
private saveTimeout: ReturnType<typeof setTimeout> | null = null; private saveTimeout: NodeJS.Timeout | null = null;
private pendingPatch: Record<string, unknown> = {};
private initialized = false; private initialized = false;
private bootstrapping = false; private bootstrapping = false;
private suppressWrites = false;
private constructor() { private constructor() {
this.data = {} as SettingsState; this.data = {} as SettingsState;
@@ -59,16 +26,9 @@ class StorageManager {
if (prop in target) { if (prop in target) {
return (target as any)[prop]; return (target as any)[prop];
} }
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
return undefined;
}
return Reflect.get(target.data, prop); return Reflect.get(target.data, prop);
}, },
set: (target, prop: keyof SettingsState, value) => { set: (target, prop: keyof SettingsState, value) => {
if (typeof prop === "string" && isExcludedSettingsKey(prop)) {
void browser.storage.local.set({ [prop]: value });
return true;
}
const oldValue = target.data[prop]; const oldValue = target.data[prop];
// Only save if the reference actually changed // Only save if the reference actually changed
@@ -135,10 +95,6 @@ class StorageManager {
key: K, key: K,
value: SettingsState[K], value: SettingsState[K],
): void { ): void {
if (typeof key === "string" && isExcludedSettingsKey(key)) {
void browser.storage.local.set({ [key]: value });
return;
}
const oldValue = this.data[key]; const oldValue = this.data[key];
if (oldValue !== value) { if (oldValue !== value) {
this.data[key] = value; this.data[key] = value;
@@ -165,64 +121,36 @@ class StorageManager {
private async loadFromStorage(): Promise<void> { private async loadFromStorage(): Promise<void> {
const result = await browser.storage.local.get(); const result = await browser.storage.local.get();
Object.entries(result).forEach(([key, value]) => { Object.entries(result).forEach(([key, value]) => {
if (isExcludedSettingsKey(key)) return;
Reflect.set(this.data, key, value); Reflect.set(this.data, key, value);
}); });
} }
public setSuppressWrites(suppress: boolean): void { public async saveToStorage(changedKeys?: string[]): Promise<void> {
this.suppressWrites = suppress; if (this.saveTimeout) {
if (!suppress) { clearTimeout(this.saveTimeout);
this.scheduleDebouncedSave(); this.saveTimeout = null;
} }
} const payload: Record<string, unknown> = {};
private queueStoragePatch(changedKeys?: string[]): void {
const keys = const keys =
changedKeys && changedKeys.length > 0 changedKeys && changedKeys.length > 0
? changedKeys ? changedKeys
: Object.keys(this.data); : Object.keys(this.data);
for (const key of keys) { for (const key of keys) {
if (isExcludedSettingsKey(key)) continue;
const value = (this.data as Record<string, unknown>)[key]; const value = (this.data as Record<string, unknown>)[key];
if (value !== undefined) { if (value !== undefined) {
this.pendingPatch[key] = value; payload[key] = value;
} }
} }
}
private scheduleDebouncedSave(): void { if (Object.keys(payload).length === 0) return;
if (this.bootstrapping || this.suppressWrites) return;
if (Object.keys(this.pendingPatch).length === 0) return;
if (this.saveTimeout) { await browser.storage.local.set(payload);
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
void this.flushPendingPatch();
}, SAVE_DEBOUNCE_MS);
}
private async flushPendingPatch(): Promise<void> {
this.saveTimeout = null;
if (this.bootstrapping || this.suppressWrites) return;
const patch = { ...this.pendingPatch };
this.pendingPatch = {};
if (Object.keys(patch).length === 0) return;
await browser.storage.local.set(patch);
if (!this.bootstrapping) { if (!this.bootstrapping) {
this.notifySubscribers(); this.notifySubscribers();
} }
} }
public saveToStorage(changedKeys?: string[]): void {
this.queueStoragePatch(changedKeys);
this.scheduleDebouncedSave();
}
private async removeFromStorage(key: string): Promise<void> { private async removeFromStorage(key: string): Promise<void> {
await browser.storage.local.remove(key); await browser.storage.local.remove(key);
} }
@@ -234,8 +162,7 @@ class StorageManager {
const actualChanges: string[] = []; const actualChanges: string[] = [];
for (const [key, { oldValue, newValue }] of Object.entries(changes)) { for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
if (storageChangeIsNoop(oldValue, newValue)) continue; if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
if (isExcludedSettingsKey(key)) continue;
if (newValue !== undefined) { if (newValue !== undefined) {
(this.data as Record<string, unknown>)[key] = newValue; (this.data as Record<string, unknown>)[key] = newValue;
@@ -337,7 +264,3 @@ class StorageManager {
export const settingsState = StorageManager.getInstance(); export const settingsState = StorageManager.getInstance();
export const initializeSettingsState = async () => export const initializeSettingsState = async () =>
await StorageManager.initialize(); await StorageManager.initialize();
export function setSettingsStateSuppressWrites(suppress: boolean): void {
settingsState.setSuppressWrites(suppress);
}
+17 -3
View File
@@ -1,15 +1,30 @@
import { OpenStorePage } from "@/seqta/ui/renderStore";
/** /**
* Module-level handoff for "open the theme store and highlight this theme". * 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; let pendingHighlightThemeId: string | null = null;
/** Read and clear the pending theme id (called by the store on mount). */
export function consumePendingHighlightThemeId(): string | null { export function consumePendingHighlightThemeId(): string | null {
const id = pendingHighlightThemeId; const id = pendingHighlightThemeId;
pendingHighlightThemeId = null; pendingHighlightThemeId = null;
return id; return id;
} }
export async function openThemeStoreWithHighlight(themeId: string): Promise<void> { /**
* 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; pendingHighlightThemeId = themeId;
const existing = document.getElementById("store"); const existing = document.getElementById("store");
@@ -20,6 +35,5 @@ export async function openThemeStoreWithHighlight(themeId: string): Promise<void
return; return;
} }
const { OpenStorePage } = await import("@/seqta/ui/renderStore"); OpenStorePage();
await OpenStorePage();
} }
-33
View File
@@ -1,33 +0,0 @@
type SettingsPopupCallback = () => void;
/**
* Singleton that notifies listeners when the in-page settings popup closes.
* Used by the colour picker and other overlays tied to ExtensionPopup.
*/
class SettingsPopup {
private static instance: SettingsPopup;
private listeners: Set<SettingsPopupCallback> = new Set();
private constructor() {}
public static getInstance(): SettingsPopup {
if (!SettingsPopup.instance) {
SettingsPopup.instance = new SettingsPopup();
}
return SettingsPopup.instance;
}
public addListener(callback: SettingsPopupCallback): void {
this.listeners.add(callback);
}
public removeListener(callback: SettingsPopupCallback): void {
this.listeners.delete(callback);
}
public triggerClose(): void {
this.listeners.forEach((callback) => callback());
}
}
export const settingsPopup = SettingsPopup.getInstance();
+1 -1
View File
@@ -17,7 +17,7 @@ export function setupSettingsButton() {
if (SettingsClicked) { if (SettingsClicked) {
closeExtensionPopup(extensionPopup as HTMLElement); closeExtensionPopup(extensionPopup as HTMLElement);
} else { } else {
await renderSettingsIfNeeded(); renderSettingsIfNeeded();
await delay(30); await delay(30);
+3 -2
View File
@@ -4,7 +4,7 @@ import DOMPurify from "dompurify";
* Converts an HTML string into a DOM element, with sanitization and optional styling. * Converts an HTML string into a DOM element, with sanitization and optional styling.
* *
* This function first sanitizes the input HTML string using DOMPurify to prevent XSS attacks. * This function first sanitizes the input HTML string using DOMPurify to prevent XSS attacks.
* The sanitization process allows only safe URI schemes in links and media. * The sanitization process allows 'onclick' attributes and specific URI schemes.
* Then, it parses the sanitized string into an HTML document and returns its body. * Then, it parses the sanitized string into an HTML document and returns its body.
* Optionally, it can apply predefined CSS styles to the body element. * Optionally, it can apply predefined CSS styles to the body element.
* *
@@ -16,8 +16,9 @@ export default function stringToHTML(str: string, styles = false) {
const parser = new DOMParser(); const parser = new DOMParser();
str = DOMPurify.sanitize(str, { str = DOMPurify.sanitize(str, {
ADD_ATTR: ["onclick"],
ALLOWED_URI_REGEXP: ALLOWED_URI_REGEXP:
/^(?:(?:https?|mailto|tel):|\/|#|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
}); });
const doc = parser.parseFromString(str, "text/html"); const doc = parser.parseFromString(str, "text/html");
+44 -38
View File
@@ -1,9 +1,12 @@
import { eventManager } from "@/seqta/utils/listeners/EventManager";
import { delay } from "@/seqta/utils/delay";
/** /**
* Asynchronously waits for an element to be present in the DOM. * Asynchronously waits for an element to be present in the DOM.
* *
* By default uses direct `querySelector` plus a targeted `MutationObserver` * This function can use either a polling mechanism (via `setTimeout`) or
* on `document.documentElement`. Polling via `setTimeout` is available as a * a `MutationObserver` (via `eventManager.register`) to detect the element.
* fallback when `usePolling` is true. * By default, it uses the `eventManager` which is more efficient.
* *
* @param {string} selector The CSS selector for the target element. * @param {string} selector The CSS selector for the target element.
* @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling. * @param {boolean} [usePolling=false] If true, forces the use of `setTimeout` for polling.
@@ -21,6 +24,9 @@ export async function waitForElm(
if (usePolling) { if (usePolling) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let iterations = 0; let iterations = 0;
if (maxIterations) {
iterations = 0;
}
const checkForElement = () => { const checkForElement = () => {
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (element) { if (element) {
@@ -30,7 +36,6 @@ export async function waitForElm(
iterations++; iterations++;
if (iterations >= maxIterations) { if (iterations >= maxIterations) {
reject(new Error("Element not found")); reject(new Error("Element not found"));
return;
} }
} }
setTimeout(checkForElement, interval); setTimeout(checkForElement, interval);
@@ -38,46 +43,47 @@ export async function waitForElm(
}; };
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", checkForElement, { document.addEventListener("DOMContentLoaded", checkForElement);
once: true,
});
} else { } else {
checkForElement(); checkForElement();
} }
}); });
} } else {
return new Promise((resolve) => {
const registerObserver = () => {
const { unregister } = eventManager.register(
`${selector}`,
{
customCheck: (element) => element.matches(selector),
},
async (element) => {
resolve(element);
await delay(1);
unregister(); // Remove the listener once the element is found
},
);
return unregister;
};
return new Promise((resolve) => { let unregister = null;
const tryResolve = (): boolean => {
const element = document.querySelector(selector); if (document.readyState === "loading") {
if (element) { // DOM is still loading, wait for it to be ready
resolve(element); document.addEventListener("DOMContentLoaded", () => {
return true; unregister = registerObserver();
});
} else {
unregister = registerObserver();
} }
return false;
};
const startObserver = () => { const querySelector = () => document.querySelector(selector);
if (tryResolve()) return; const element = querySelector();
const observer = new MutationObserver(() => { if (element) {
if (tryResolve()) { if (unregister) unregister();
observer.disconnect(); resolve(element);
} return;
}); }
});
observer.observe(document.documentElement, { }
childList: true,
subtree: true,
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver, {
once: true,
});
} else {
startObserver();
}
});
} }
+5 -1
View File
@@ -69,7 +69,11 @@ export interface SettingsState {
assessmentsAverage?: boolean; assessmentsAverage?: boolean;
notificationCollector?: boolean; notificationCollector?: boolean;
// BetterSEQTA Cloud (accounts.betterseqta.org) — stored via CloudAuth, not settingsState // BetterSEQTA Cloud (accounts.betterseqta.org)
bsplus_client_id?: string;
bsplus_token?: string;
bsplus_refresh_token?: string;
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; pfpHash?: string | null; admin_level?: number };
/** When not `false`, automatic cloud settings sync is enabled (default-on). */ /** When not `false`, automatic cloud settings sync is enabled (default-on). */
autoCloudSettingsSync?: boolean; autoCloudSettingsSync?: boolean;
} }
-1
View File
@@ -215,7 +215,6 @@ export async function checkGithubReleaseUpdate(): Promise<GhReleaseUpdateInfo> {
} }
export function dismissNightlyUpdate(): void { export function dismissNightlyUpdate(): void {
cachedResult = null;
void (async () => { void (async () => {
const release = await fetchJson<GhRelease>( const release = await fetchJson<GhRelease>(
`https://api.github.com/repos/${getRepoSlug()}/releases/tags/${NIGHTLY_TAG}`, `https://api.github.com/repos/${getRepoSlug()}/releases/tags/${NIGHTLY_TAG}`,
-4
View File
@@ -60,10 +60,6 @@ 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 }) => ({
// Content scripts run on the host page; absolute `/assets/...` URLs would
// resolve against SEQTA instead of chrome-extension://. Relative base makes
// Vite emit import.meta.url-relative chunk/CSS URLs at runtime.
base: command === "build" ? "./" : "/",
define: { define: {
__ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify( __ENABLE_GH_RELEASE_UPDATE_CHECK__: JSON.stringify(
process.env.GH_RELEASE_UPDATE_CHECK === "true", process.env.GH_RELEASE_UPDATE_CHECK === "true",