mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-17 17:07:07 +00:00
fix: harden extension security and plugin reliability
Address audit findings across background handlers, openers, plugins, and UI: URL allowlists, XSS reductions, popup lifecycle fixes, plugin dispose/cleanup, cloud sync hardening, global search mathjs sandbox, and settings storage fixes.
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
processAssessments,
|
||||
type WeightingEntry,
|
||||
} from "./utils.ts";
|
||||
import { injectRubricCopyButtons } from "./rubricCopy.ts";
|
||||
import { injectRubricCopyButtons, teardownRubricCopyButtons } from "./rubricCopy.ts";
|
||||
|
||||
interface weightingsStorage {
|
||||
weightings: Record<string, WeightingEntry>;
|
||||
@@ -41,6 +41,8 @@ class AssessmentsAveragePluginClass extends BasePlugin<typeof settings> {
|
||||
const instance = new AssessmentsAveragePluginClass();
|
||||
|
||||
let overrideListenerController: AbortController | null = null;
|
||||
let wrapperColourObserver: MutationObserver | null = null;
|
||||
let wrapperColourObserverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
id: "assessments-average",
|
||||
@@ -54,7 +56,9 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
await initStorage(api);
|
||||
clearStuck(api);
|
||||
|
||||
api.seqta.onMount(".assessmentsWrapper", async () => {
|
||||
const { unregister: unregisterWrapperMount } = api.seqta.onMount(
|
||||
".assessmentsWrapper",
|
||||
async () => {
|
||||
await waitForElm(
|
||||
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
|
||||
true,
|
||||
@@ -88,17 +92,43 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
|
||||
void parseAssessments(api);
|
||||
const wrapper = document.querySelector(".assessmentsWrapper");
|
||||
if (wrapper) {
|
||||
const observer = new MutationObserver(() => {
|
||||
wrapperColourObserver?.disconnect();
|
||||
if (wrapperColourObserverTimeout) {
|
||||
clearTimeout(wrapperColourObserverTimeout);
|
||||
}
|
||||
wrapperColourObserver = new MutationObserver(() => {
|
||||
applySubjectColourToOverallResult();
|
||||
});
|
||||
observer.observe(wrapper, { childList: true, subtree: true });
|
||||
setTimeout(() => observer.disconnect(), 10000);
|
||||
wrapperColourObserver.observe(wrapper, { childList: true, subtree: true });
|
||||
wrapperColourObserverTimeout = setTimeout(() => {
|
||||
wrapperColourObserver?.disconnect();
|
||||
wrapperColourObserver = null;
|
||||
wrapperColourObserverTimeout = null;
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
api.seqta.onMount("[class*='SelectedAssessment__']", () => {
|
||||
},
|
||||
);
|
||||
const { unregister: unregisterSelectedMount } = api.seqta.onMount(
|
||||
"[class*='SelectedAssessment__']",
|
||||
() => {
|
||||
injectWeightingsTab(api);
|
||||
injectRubricCopyButtons();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
overrideListenerController?.abort();
|
||||
overrideListenerController = null;
|
||||
wrapperColourObserver?.disconnect();
|
||||
wrapperColourObserver = null;
|
||||
if (wrapperColourObserverTimeout) {
|
||||
clearTimeout(wrapperColourObserverTimeout);
|
||||
wrapperColourObserverTimeout = null;
|
||||
}
|
||||
teardownRubricCopyButtons();
|
||||
unregisterWrapperMount();
|
||||
unregisterSelectedMount();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,91 @@ export interface 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 {
|
||||
const score =
|
||||
mark?.results?.percentage ?? mark?.results?.score ?? null;
|
||||
@@ -264,6 +349,7 @@ function createWeightLabel(
|
||||
weighting: string | undefined,
|
||||
api: any,
|
||||
refreshing = false,
|
||||
assessmentID?: string,
|
||||
) {
|
||||
let statsContainer = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__stats___'], .betterseqta-stats-container`,
|
||||
@@ -289,10 +375,8 @@ function createWeightLabel(
|
||||
? "space-between"
|
||||
: "flex-end";
|
||||
|
||||
const title = assessmentItem
|
||||
.querySelector(`[class*='AssessmentItem__title___']`)
|
||||
?.textContent?.trim();
|
||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||
const resolvedAssessmentId =
|
||||
assessmentID ?? assessmentItem.dataset.betterseqtaAssessmentId;
|
||||
|
||||
const existingLabel = statsContainer.querySelector(
|
||||
".betterseqta-weight-label",
|
||||
@@ -302,7 +386,7 @@ function createWeightLabel(
|
||||
updateWeightLabelContent(
|
||||
existingLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
resolvedAssessmentId,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
@@ -340,7 +424,7 @@ function createWeightLabel(
|
||||
updateWeightLabelContent(
|
||||
weightLabel,
|
||||
weighting,
|
||||
assessmentID,
|
||||
resolvedAssessmentId,
|
||||
api,
|
||||
refreshing,
|
||||
);
|
||||
@@ -352,14 +436,20 @@ export const isFirefox =
|
||||
!navigator.userAgent.toLowerCase().includes("seamonkey") &&
|
||||
!navigator.userAgent.toLowerCase().includes("waterfox");
|
||||
|
||||
function trustedPageOrigin(): string {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
const isBlobUrl = url.startsWith("blob:");
|
||||
const pageOrigin = trustedPageOrigin();
|
||||
|
||||
if (isBlobUrl || isFirefox) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
|
||||
const escapedUrl = url.replace(/'/g, "\\'");
|
||||
const escapedOrigin = pageOrigin.replace(/'/g, "\\'");
|
||||
|
||||
script.textContent = `
|
||||
(function() {
|
||||
@@ -375,19 +465,20 @@ async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
|
||||
type: '${requestId}',
|
||||
success: true,
|
||||
data: Array.from(new Uint8Array(arrayBuffer))
|
||||
}, '*');
|
||||
}, '${escapedOrigin}');
|
||||
})
|
||||
.catch(error => {
|
||||
window.postMessage({
|
||||
type: '${requestId}',
|
||||
success: false,
|
||||
error: error.message || String(error)
|
||||
}, '*');
|
||||
}, '${escapedOrigin}');
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== pageOrigin || event.source !== window) return;
|
||||
if (event.data?.type === requestId) {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
if (script.parentNode) {
|
||||
@@ -454,6 +545,9 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
const pdfLibInj = escJsSingleQuoted(pdfLibUrl);
|
||||
const pdfWorkerInj = escJsSingleQuoted(pdfWorkerUrl);
|
||||
|
||||
const pageOrigin = trustedPageOrigin();
|
||||
const escapedOrigin = pageOrigin.replace(/'/g, "\\'");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
|
||||
@@ -466,6 +560,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
script.textContent = `
|
||||
(function() {
|
||||
const requestId = '${requestId}';
|
||||
const pageOrigin = '${escapedOrigin}';
|
||||
const url = '${escapedUrl}';
|
||||
const pdfLibSrc = '${pdfLibInj}';
|
||||
const pdfWorkerSrc = '${pdfWorkerInj}';
|
||||
@@ -485,7 +580,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Failed to load pdfjs library'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
document.head.appendChild(pdfjsScript);
|
||||
@@ -506,7 +601,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -542,21 +637,21 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: true,
|
||||
text: text
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
})
|
||||
.catch(error => {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'PDF parsing error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
});
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'ArrayBuffer error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -565,7 +660,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Network error fetching PDF'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
@@ -573,7 +668,7 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Timeout fetching PDF'
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
};
|
||||
|
||||
xhr.timeout = 30000;
|
||||
@@ -583,13 +678,14 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
type: requestId,
|
||||
success: false,
|
||||
error: 'Setup error: ' + (error.message || String(error))
|
||||
}, '*');
|
||||
}, pageOrigin);
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== pageOrigin || event.source !== window) return;
|
||||
if (event.data?.type === requestId) {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
if (script.parentNode) {
|
||||
@@ -646,9 +742,8 @@ export async function extractPDFText(url: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function handleWeightings(mark: any, api: any) {
|
||||
const assessmentID = mark.id;
|
||||
const assessmentID = assessmentIdKey(mark);
|
||||
const metaclassID = mark.metaclassID;
|
||||
const title = mark.title;
|
||||
|
||||
const fingerprint = computeFingerprint(mark);
|
||||
const existing = api.storage.weightings[assessmentID] as
|
||||
@@ -687,10 +782,7 @@ async function handleWeightings(mark: any, api: any) {
|
||||
[assessmentID]: placeholder,
|
||||
};
|
||||
|
||||
api.storage.assessments = {
|
||||
...api.storage.assessments,
|
||||
[title.trim()]: assessmentID,
|
||||
};
|
||||
registerAssessmentLookup(api, mark);
|
||||
|
||||
// Surface the refreshing indicator on the affected row immediately,
|
||||
// without waiting for the PDF fetch to finish.
|
||||
@@ -813,6 +905,16 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
let hasRefreshingWeighting = false;
|
||||
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) {
|
||||
const titleEl = assessmentItem.querySelector(
|
||||
`[class*='AssessmentItem__title___']`,
|
||||
@@ -822,7 +924,11 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
const title = titleEl.textContent?.trim();
|
||||
if (!title) continue;
|
||||
|
||||
const assessmentID = api.storage.assessments?.[title];
|
||||
const assessmentID = await resolveAssessmentId(api, title, fiberMarks);
|
||||
if (assessmentID) {
|
||||
(assessmentItem as HTMLElement).dataset.betterseqtaAssessmentId =
|
||||
assessmentID;
|
||||
}
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
: undefined;
|
||||
@@ -833,7 +939,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) {
|
||||
const weighting = override ?? autoWeighting;
|
||||
const refreshing = !override && Boolean(entry?.refreshing);
|
||||
|
||||
createWeightLabel(assessmentItem, weighting, api, refreshing);
|
||||
createWeightLabel(assessmentItem, weighting, api, refreshing, assessmentID);
|
||||
|
||||
const gradeElement = assessmentItem.querySelector(
|
||||
`[class*='Thermoscore__text___']`,
|
||||
@@ -935,12 +1041,17 @@ function resolveTabSetClasses(): Record<string, string> {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||
const titleEl = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']",
|
||||
async function buildWeightingsTabContent(api: any, sheet: HTMLElement) {
|
||||
const selectedItem = document.querySelector(
|
||||
"[class*='AssessmentItem__AssessmentItem___'][class*='selected___']",
|
||||
) as HTMLElement | null;
|
||||
const titleEl = selectedItem?.querySelector(
|
||||
"[class*='AssessmentItem__title___']",
|
||||
);
|
||||
const title = titleEl?.textContent?.trim();
|
||||
const assessmentID = title ? api.storage.assessments?.[title] : undefined;
|
||||
const assessmentID =
|
||||
selectedItem?.dataset.betterseqtaAssessmentId ??
|
||||
(title ? await resolveAssessmentId(api, title) : undefined);
|
||||
|
||||
const entry = assessmentID
|
||||
? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined)
|
||||
@@ -1093,7 +1204,7 @@ export function injectWeightingsTab(api: any) {
|
||||
container.appendChild(newSheet);
|
||||
|
||||
newTab.addEventListener("click", () => {
|
||||
buildWeightingsTabContent(api, newSheet);
|
||||
void buildWeightingsTabContent(api, newSheet);
|
||||
});
|
||||
|
||||
const allTabs = Array.from(tabList.querySelectorAll("li"));
|
||||
@@ -1107,20 +1218,22 @@ export function injectWeightingsTab(api: any) {
|
||||
t.className.includes("TabSet__selected___"),
|
||||
);
|
||||
if (i === currentIndex) return;
|
||||
const goingRight = i > currentIndex;
|
||||
const goingRight = currentIndex < 0 ? true : i > currentIndex;
|
||||
|
||||
allTabs.forEach((t) => {
|
||||
t.className = "";
|
||||
t.setAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
allSheets[currentIndex].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
cls["TabSet__hidden___"],
|
||||
goingRight
|
||||
? cls["TabSet__disappearToLeft___"]
|
||||
: cls["TabSet__disappearToRight___"],
|
||||
].join(" ");
|
||||
if (currentIndex >= 0) {
|
||||
allSheets[currentIndex].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
cls["TabSet__hidden___"],
|
||||
goingRight
|
||||
? cls["TabSet__disappearToLeft___"]
|
||||
: cls["TabSet__disappearToRight___"],
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
allSheets[i].className = [
|
||||
cls["TabSet__tabsheet___"],
|
||||
|
||||
@@ -29,6 +29,9 @@ async function fetchJSON(url: string, body: any) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -164,7 +167,7 @@ async function getLearnAssessmentsData(studentId: number) {
|
||||
}
|
||||
|
||||
export async function getAssessmentsData() {
|
||||
if (settingsState.mockNotices) {
|
||||
if (settingsState.hideSensitiveContent) {
|
||||
return getMockAssessmentsData();
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ async function fetchJSON(url: string, body: unknown) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Plugin } from "../../core/types";
|
||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||
import { getAssessmentsData } from "./api";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader } from "./ui";
|
||||
import { renderErrorState, renderGrid, renderSkeletonLoader, teardownOverviewUi } from "./ui";
|
||||
import styles from "./styles.css?inline";
|
||||
import { delay } from "@/seqta/utils/delay";
|
||||
import { isSeqtaEngageExperience } from "@/seqta/utils/isSeqtaEngage";
|
||||
@@ -66,6 +66,8 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
gridItem.appendChild(label);
|
||||
menu.insertBefore(gridItem, menu.firstChild);
|
||||
|
||||
let loadRequestId = 0;
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
ensureOverviewMenuPosition(menu, gridItem);
|
||||
});
|
||||
@@ -81,7 +83,18 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
};
|
||||
gridItem.addEventListener("click", clickHandler);
|
||||
|
||||
const popstateHandler = () => {
|
||||
if (isOverviewRoute()) {
|
||||
void loadGridView();
|
||||
} else {
|
||||
loadRequestId += 1;
|
||||
teardownOverviewUi();
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", popstateHandler);
|
||||
|
||||
async function loadGridView() {
|
||||
const requestId = ++loadRequestId;
|
||||
await delay(1);
|
||||
|
||||
if (isSeqtaEngageExperience()) {
|
||||
@@ -98,7 +111,7 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
const main = document.getElementById("main");
|
||||
if (!main) return;
|
||||
if (!main || requestId !== loadRequestId) return;
|
||||
|
||||
document
|
||||
.querySelectorAll('[data-key="assessments"] .item')
|
||||
@@ -110,17 +123,22 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
.querySelector('[data-key="assessments"]')
|
||||
?.classList.add("active");
|
||||
|
||||
main.innerHTML = '<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||
main.innerHTML =
|
||||
'<div id="grid-view-container" class="bsplus-overview-host"></div>';
|
||||
const container = document.getElementById(
|
||||
"grid-view-container",
|
||||
) as HTMLElement;
|
||||
|
||||
if (requestId !== loadRequestId) return;
|
||||
|
||||
renderSkeletonLoader(container);
|
||||
|
||||
try {
|
||||
const data = await getAssessmentsData();
|
||||
if (requestId !== loadRequestId) return;
|
||||
renderGrid(container, data);
|
||||
} catch (err) {
|
||||
if (requestId !== loadRequestId) return;
|
||||
console.error("Failed to load assessments:", err);
|
||||
renderErrorState(
|
||||
container,
|
||||
@@ -130,8 +148,11 @@ const assessmentsOverviewPlugin: Plugin<{}> = {
|
||||
}
|
||||
|
||||
return () => {
|
||||
loadRequestId += 1;
|
||||
window.removeEventListener("popstate", popstateHandler);
|
||||
menuObserver.disconnect();
|
||||
gridItem.removeEventListener("click", clickHandler);
|
||||
teardownOverviewUi();
|
||||
gridItem.remove();
|
||||
};
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@ export function activeSubjectsFromEngageChild(child: {
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const term of child.terms ?? []) {
|
||||
if (term.active !== 1) continue;
|
||||
if (!isActiveTermFlag(term.active)) continue;
|
||||
for (const raw of term.subjects ?? []) {
|
||||
const subject = normalizeOverviewSubject(raw);
|
||||
if (!subject) continue;
|
||||
@@ -151,7 +151,14 @@ export function determineStatus(item: any): string {
|
||||
}
|
||||
|
||||
const completedKey = "betterseqta-completed-assessments";
|
||||
const completed = JSON.parse(localStorage.getItem(completedKey) || "[]");
|
||||
let completed: unknown[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(completedKey);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
completed = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
completed = [];
|
||||
}
|
||||
if (completed.includes(item.id)) {
|
||||
return "MARKS_RELEASED";
|
||||
}
|
||||
|
||||
@@ -74,7 +74,10 @@ function ensureGestureStart(handler: () => void): () => void {
|
||||
|
||||
async function startPlayback(volume: number): Promise<void> {
|
||||
const blob = await loadAudioBlob();
|
||||
if (!blob) return;
|
||||
if (!blob) {
|
||||
stopAndCleanupAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
stopAndCleanupAudio();
|
||||
|
||||
@@ -123,7 +126,7 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Stop button/event removed by user; no stop handling needed
|
||||
// Note: Stop button dispatches betterseqta-background-music-stop on remove
|
||||
|
||||
// Start if we have audio and autoplay is enabled
|
||||
const tryStart = async () => {
|
||||
@@ -160,16 +163,21 @@ const backgroundMusicPlugin: Plugin<typeof settings> = {
|
||||
};
|
||||
document.addEventListener("visibilitychange", visHandler);
|
||||
|
||||
// Allow uploads to trigger refresh
|
||||
// Allow uploads to trigger refresh; stop event clears playback on remove
|
||||
const uploadedHandler = () => {
|
||||
const vol = (api.settings as any).volume ?? 0.5;
|
||||
startPlayback(vol);
|
||||
};
|
||||
const stopHandler = () => {
|
||||
stopAndCleanupAudio();
|
||||
};
|
||||
window.addEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||
window.addEventListener("betterseqta-background-music-stop", stopHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", visHandler);
|
||||
window.removeEventListener("betterseqta-background-music-updated", uploadedHandler);
|
||||
window.removeEventListener("betterseqta-background-music-stop", stopHandler);
|
||||
if (cleanupRegistered && (window as any).__betterseqta_bg_music_cancel__) {
|
||||
(window as any).__betterseqta_bg_music_cancel__();
|
||||
(window as any).__betterseqta_bg_music_cancel__ = undefined;
|
||||
|
||||
@@ -255,9 +255,9 @@ const watchNavigator = (navigator: Element, onChange: () => void) => {
|
||||
return observer;
|
||||
};
|
||||
|
||||
const handleSlidePane = (pane: Element) => {
|
||||
const handleSlidePane = (pane: Element): (() => void) => {
|
||||
const navigator = pane.querySelector(".navigator");
|
||||
if (!navigator) return;
|
||||
if (!navigator) return () => {};
|
||||
|
||||
requestAnimationFrame(() => scrollSelectedIntoView(navigator));
|
||||
setTimeout(() => scrollSelectedIntoView(navigator), 50);
|
||||
@@ -272,17 +272,22 @@ const handleSlidePane = (pane: Element) => {
|
||||
childList: true,
|
||||
});
|
||||
|
||||
const cleanup = new MutationObserver((muts) => {
|
||||
const paneCleanup = new MutationObserver((muts) => {
|
||||
muts.forEach((m) => {
|
||||
m.removedNodes.forEach((n) => {
|
||||
if (n === pane) {
|
||||
observer.disconnect();
|
||||
cleanup.disconnect();
|
||||
paneCleanup.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
cleanup.observe(document.body, { childList: true });
|
||||
paneCleanup.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
paneCleanup.disconnect();
|
||||
};
|
||||
};
|
||||
|
||||
const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
@@ -301,7 +306,11 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
window.addEventListener("resize", positionArrows);
|
||||
window.addEventListener("scroll", positionArrows, true);
|
||||
|
||||
api.seqta.onMount(".course", async (element) => {
|
||||
const navObservers: MutationObserver[] = [];
|
||||
const courseObservers: MutationObserver[] = [];
|
||||
const slidePaneCleanups: Array<() => void> = [];
|
||||
|
||||
const courseMount = api.seqta.onMount(".course", async (element) => {
|
||||
const course = element as HTMLElement;
|
||||
let navObserver: MutationObserver | null = null;
|
||||
|
||||
@@ -318,6 +327,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
}
|
||||
ensureArrows(course);
|
||||
});
|
||||
navObservers.push(navObserver);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -325,6 +335,7 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
const courseObserver = new MutationObserver(() => {
|
||||
if (setup()) courseObserver.disconnect();
|
||||
});
|
||||
courseObservers.push(courseObserver);
|
||||
courseObserver.observe(course, { childList: true, subtree: true });
|
||||
}
|
||||
});
|
||||
@@ -334,13 +345,21 @@ const enhancedNavigationPlugin: Plugin<typeof settings> = {
|
||||
m.addedNodes.forEach((n) => {
|
||||
if (n.nodeType !== 1) return;
|
||||
const el = n as Element;
|
||||
if (el.classList?.contains("uiSlidePane")) handleSlidePane(el);
|
||||
if (el.classList?.contains("uiSlidePane")) {
|
||||
slidePaneCleanups.push(handleSlidePane(el));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
bodyObserver.observe(document.body, { childList: true });
|
||||
|
||||
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();
|
||||
document.getElementById(ARROW_CONTAINER_ID)?.remove();
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
|
||||
@@ -175,29 +175,35 @@
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
const requestId = ++searchRequestId;
|
||||
|
||||
if (commandsFuse && dynamicContentFuse) {
|
||||
const results = await doSearch(
|
||||
term,
|
||||
commandsFuse,
|
||||
commandIdToItemMap,
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
true, // sortByRecent
|
||||
);
|
||||
try {
|
||||
if (commandsFuse && dynamicContentFuse) {
|
||||
const results = await doSearch(
|
||||
term,
|
||||
commandsFuse,
|
||||
commandIdToItemMap,
|
||||
dynamicContentFuse,
|
||||
dynamicIdToItemMap,
|
||||
true, // sortByRecent
|
||||
);
|
||||
|
||||
// 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
|
||||
// keeps the visible list anchored to the latest query.
|
||||
if (requestId !== searchRequestId) return;
|
||||
if (searchTerm.trim().toLowerCase() !== term) return;
|
||||
// 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
|
||||
// keeps the visible list anchored to the latest query.
|
||||
if (requestId !== searchRequestId) return;
|
||||
if (searchTerm.trim().toLowerCase() !== term) return;
|
||||
|
||||
combinedResults = results;
|
||||
} else {
|
||||
if (requestId !== searchRequestId) return;
|
||||
combinedResults = [];
|
||||
combinedResults = results;
|
||||
} else {
|
||||
if (requestId !== searchRequestId) return;
|
||||
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
|
||||
|
||||
@@ -214,7 +214,7 @@ const staticCommands: StaticCommandItem[] = [
|
||||
code: 'KeyM',
|
||||
keyCode: 77,
|
||||
altKey: true
|
||||
}, "*");
|
||||
}, location.origin);
|
||||
},
|
||||
keywords: ["compose", "message", "dm", "direct message", "new message"],
|
||||
priority: 3,
|
||||
|
||||
@@ -37,6 +37,41 @@ export function mountSearchBar(
|
||||
const searchButton = document.createElement("div");
|
||||
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");
|
||||
progressBarWrapper.className = "search-progress-bar-wrapper";
|
||||
|
||||
@@ -234,14 +269,10 @@ export function mountSearchBar(
|
||||
appRef.clearDoneFlashTimer = clearDoneFlashTimer;
|
||||
|
||||
const updateSearchButtonDisplay = () => {
|
||||
searchButton.innerHTML = /* html */ `
|
||||
<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">
|
||||
<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>
|
||||
`;
|
||||
hotkeySpan.textContent = hotkeyDisplay;
|
||||
if (!searchButton.contains(searchIcon)) {
|
||||
searchButton.replaceChildren(searchIcon, searchLabel, hotkeySpan);
|
||||
}
|
||||
};
|
||||
|
||||
updateSearchButtonDisplay();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { renderComponentMap } from "./renderComponents";
|
||||
import type { IndexItem, Job, JobContext } from "./types";
|
||||
import { VectorWorkerManager } from "./worker/vectorWorkerManager";
|
||||
import { loadDynamicItems } from "../utils/dynamicItems";
|
||||
import { getVectorizedItemIds } from "./utils";
|
||||
import { getVectorizedItemIds, pruneOrphanVectorEmbeddings } from "./utils";
|
||||
import { INDEX_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schemaVersion";
|
||||
|
||||
const META_STORE = "meta";
|
||||
@@ -220,6 +220,7 @@ export async function runIndexing(): Promise<void> {
|
||||
startHeartbeat();
|
||||
console.debug("%c[Indexer] Starting indexing...", "color: green");
|
||||
|
||||
try {
|
||||
const jobIds = Object.keys(jobs);
|
||||
let completedJobs = 0;
|
||||
const totalSteps = jobIds.length + 1;
|
||||
@@ -320,6 +321,17 @@ export async function runIndexing(): Promise<void> {
|
||||
|
||||
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) {
|
||||
console.debug(
|
||||
`%c[Indexer] Checking ${allItemsInPrimaryStores.length} items for vectorization...`,
|
||||
@@ -434,8 +446,6 @@ export async function runIndexing(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
stopHeartbeat();
|
||||
|
||||
allItemsInPrimaryStores = await loadAllStoredItems();
|
||||
// Create new objects to avoid XrayWrapper issues in Firefox
|
||||
const itemsWithComponents = allItemsInPrimaryStores.map(item => {
|
||||
@@ -466,6 +476,9 @@ export async function runIndexing(): Promise<void> {
|
||||
});
|
||||
loadDynamicItems(itemsWithComponents);
|
||||
window.dispatchEvent(new Event("dynamic-items-updated"));
|
||||
} finally {
|
||||
stopHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
function mergeItems(existing: IndexItem[], incoming: IndexItem[]): IndexItem[] {
|
||||
|
||||
@@ -53,6 +53,75 @@ 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 {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawHtml, "text/html");
|
||||
|
||||
@@ -242,11 +242,12 @@ export async function hybridSearch(
|
||||
export async function hybridSearchWithExpansion(
|
||||
bm25Results: CombinedResult[],
|
||||
query: string,
|
||||
_allItems: IndexItem[],
|
||||
allItems: IndexItem[],
|
||||
options: HybridSearchOptions = {},
|
||||
): Promise<CombinedResult[]> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
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);
|
||||
@@ -298,6 +299,9 @@ export async function hybridSearchWithExpansion(
|
||||
vectorResults.forEach(v => {
|
||||
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
|
||||
const item = v.object;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as math from 'mathjs';
|
||||
import { create, all, typeOf as mathTypeOf, format as mathFormat } from 'mathjs';
|
||||
import { unitFullNames } from './unitMap';
|
||||
|
||||
export interface CalculatorResult {
|
||||
@@ -10,66 +10,42 @@ export interface CalculatorResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const expandedMath = math.create(math.all);
|
||||
/** Hard cap on calculator input length to limit parse/eval cost. */
|
||||
export const CALCULATOR_MAX_INPUT_LENGTH = 128;
|
||||
|
||||
expandedMath.import({
|
||||
five: 5,
|
||||
ten: 10,
|
||||
three: 3,
|
||||
four: 4,
|
||||
eight: 8,
|
||||
sixteen: 16,
|
||||
twenty: 20,
|
||||
twentyfive: 25,
|
||||
fifty: 50,
|
||||
hundred: 100,
|
||||
plus: (a: number, b: number) => a + b,
|
||||
minus: (a: number, b: number) => a - b,
|
||||
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,
|
||||
/**
|
||||
* Functions safe to replace with stubs. Do not block type constructors
|
||||
* (`complex`, `typed`, `fraction`, `bignumber`, `sparse`) or parse pipeline
|
||||
* (`parse`, `compile`, `parser`) — mathjs needs those internally and
|
||||
* `evaluate()` depends on them.
|
||||
*/
|
||||
const BLOCKED_MATH_FUNCTIONS = [
|
||||
'import',
|
||||
'createUnit',
|
||||
'random',
|
||||
'pickRandom',
|
||||
'chain',
|
||||
'help',
|
||||
] as const;
|
||||
|
||||
// String functions
|
||||
length: (str: string) => str.length,
|
||||
concat: (...args: string[]) => args.join(''),
|
||||
uppercase: (str: string) => str.toUpperCase(),
|
||||
lowercase: (str: string) => str.toLowerCase(),
|
||||
substr: (str: string, start: number, length: number) => str.substr(start, length),
|
||||
function createSandboxedMath() {
|
||||
const sandbox = create(all);
|
||||
const blockFn = () => {
|
||||
throw new Error('Function not allowed');
|
||||
};
|
||||
const blocked: Record<string, () => never> = {};
|
||||
for (const name of BLOCKED_MATH_FUNCTIONS) {
|
||||
blocked[name] = blockFn;
|
||||
}
|
||||
sandbox.import(blocked, { override: true });
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
// 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 });
|
||||
const calculatorMath = createSandboxedMath();
|
||||
|
||||
function detectUnit(expression: string): string {
|
||||
try {
|
||||
const unit = expandedMath.unit(expression);
|
||||
const unit = calculatorMath.unit(expression);
|
||||
if (unit) {
|
||||
const unitStr = unit.formatUnits();
|
||||
return unitFullNames[unitStr] || unitStr;
|
||||
@@ -120,9 +96,9 @@ function tryCompleteExpression(expression: string): string | null {
|
||||
// Handle cases like "4 + 3 *" -> evaluate "4 + 3"
|
||||
if (partial && !partial.match(/[\+\-\*\/\^]\s*$/)) {
|
||||
try {
|
||||
const result = expandedMath.evaluate(partial);
|
||||
const result = calculatorMath.evaluate(partial);
|
||||
if (typeof result === 'number' && !isNaN(result)) {
|
||||
return expandedMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
return calculatorMath.format(result, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to other attempts
|
||||
@@ -147,6 +123,17 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
outputUnit: '',
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
if (!isLikelyMathExpression(trimmed)) {
|
||||
@@ -161,23 +148,23 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
|
||||
try {
|
||||
// First try to evaluate the expression as-is
|
||||
const evaluated = expandedMath.evaluate(trimmed.replace('**', '^'));
|
||||
const evaluated = calculatorMath.evaluate(trimmed.replace('**', '^'));
|
||||
|
||||
if (evaluated !== undefined) {
|
||||
let result: string;
|
||||
let inputUnit = '';
|
||||
let outputUnit = '';
|
||||
|
||||
if (math.typeOf(evaluated) === 'Unit') {
|
||||
if (mathTypeOf(evaluated) === 'Unit') {
|
||||
// Handle unit conversion results
|
||||
result = expandedMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = calculatorMath.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
inputUnit = detectUnit(trimmed);
|
||||
outputUnit = detectUnit(result);
|
||||
} else if (typeof evaluated === 'number') {
|
||||
// Handle regular numbers
|
||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
} else {
|
||||
result = math.format(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
result = mathFormat(evaluated, { precision: 14, lowerExp: -15, upperExp: 15 });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -220,4 +207,4 @@ export function calculateExpression(input: string): CalculatorResult {
|
||||
inputUnit: '',
|
||||
outputUnit: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ export interface ParsedHotkey {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Single-key allowlist: a-z, 0-9, and F1–F12 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 {
|
||||
const parts = hotkeyString.toLowerCase().split('+').map(part => part.trim()).filter(part => part.length > 0);
|
||||
|
||||
@@ -68,14 +76,14 @@ export function formatHotkeyForDisplay(hotkeyString: string): string {
|
||||
parts.push(isMac ? '⇧' : 'Shift');
|
||||
}
|
||||
|
||||
if (parsed.key) {
|
||||
if (parsed.key && isAllowedHotkeyKey(parsed.key)) {
|
||||
parts.push(parsed.key.toUpperCase());
|
||||
}
|
||||
|
||||
return parts.join(isMac ? ' ' : '+');
|
||||
} catch (error) {
|
||||
console.warn('Invalid hotkey string:', hotkeyString);
|
||||
return hotkeyString; // Fallback to original string
|
||||
return 'Ctrl+K';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +92,7 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
|
||||
// If no key is specified, don't match anything
|
||||
if (!parsed.key) {
|
||||
if (!parsed.key || !isAllowedHotkeyKey(parsed.key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -111,8 +119,8 @@ export function matchesHotkey(event: KeyboardEvent, hotkeyString: string): boole
|
||||
export function isValidHotkey(hotkeyString: string): boolean {
|
||||
try {
|
||||
const parsed = parseHotkey(hotkeyString);
|
||||
return parsed.key.length > 0;
|
||||
return parsed.key.length > 0 && isAllowedHotkeyKey(parsed.key);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
xScale={scaleBand().padding(distribution().modeUsed === "letter" ? 0.22 : 0.28)}
|
||||
|
||||
yScale={yScale()}
|
||||
yScale={yScale}
|
||||
|
||||
x="grade"
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
sortedData.length;
|
||||
itemsPerPage;
|
||||
const maxPage = Math.max(0, pageCount - 1);
|
||||
if (currentPage > maxPage) {
|
||||
currentPage = maxPage;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSort(column: keyof Assessment) {
|
||||
if (sortColumn === column) {
|
||||
sortDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
|
||||
@@ -53,8 +53,9 @@
|
||||
const [minG, maxG] = gradeRange;
|
||||
return analyticsData.filter((a) => {
|
||||
if (filterSubjects.length && !filterSubjects.includes(a.subject)) return false;
|
||||
const grade = a.finalGrade ?? -1;
|
||||
if (grade < minG || grade > maxG) return false;
|
||||
if (a.finalGrade !== undefined) {
|
||||
if (a.finalGrade < minG || a.finalGrade > maxG) return false;
|
||||
}
|
||||
if (
|
||||
filterSearch &&
|
||||
!a.title.toLowerCase().includes(filterSearch.toLowerCase()) &&
|
||||
|
||||
@@ -24,6 +24,9 @@ async function fetchJSON(url: string, body: Record<string, unknown>) {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -254,10 +257,19 @@ async function loadAllPast(
|
||||
const results: Record<string, unknown>[][] = [];
|
||||
for (let i = 0; i < subjects.length; i += PAST_FETCH_CONCURRENCY) {
|
||||
const batch = subjects.slice(i, i + PAST_FETCH_CONCURRENCY);
|
||||
const batchResults = await Promise.all(
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((s) => loadPastForSubject(studentId, s)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
for (const result of batchResults) {
|
||||
if (result.status === "fulfilled") {
|
||||
results.push(result.value);
|
||||
} else {
|
||||
console.error(
|
||||
"[BetterSEQTA+] Past assessments fetch failed:",
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.flat();
|
||||
}
|
||||
@@ -295,7 +307,7 @@ function mergeRawAssessments(
|
||||
}
|
||||
|
||||
export async function getStudentId(): Promise<number> {
|
||||
const info = await getUserInfo();
|
||||
const info = await getUserInfo({ validateSession: true });
|
||||
const id = Number(info?.id);
|
||||
if (!id || isNaN(id)) throw new Error("Could not resolve student ID");
|
||||
return id;
|
||||
|
||||
@@ -67,6 +67,44 @@ function generateId(): string {
|
||||
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> = {
|
||||
id: "messageFolders",
|
||||
name: "Message Folders",
|
||||
@@ -95,7 +133,8 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
let foldedSection: HTMLElement | null = null;
|
||||
const unregisters: Array<{ unregister: () => void }> = [];
|
||||
|
||||
const getFolders = (): Folder[] => api.storage.folders ?? [];
|
||||
const getFolders = (): Folder[] =>
|
||||
(api.storage.folders ?? []).map((folder) => normalizeFolder(folder));
|
||||
const getAssignments = (): Record<string, string[]> => api.storage.messageAssignments ?? {};
|
||||
|
||||
const saveFolders = (folders: Folder[]) => {
|
||||
@@ -298,7 +337,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
item.appendChild(iconSpan);
|
||||
|
||||
const name = document.createElement("span");
|
||||
@@ -622,7 +661,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
@@ -725,7 +764,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
dot.style.background = folder.color;
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "bsplus-folder-icon";
|
||||
iconSpan.innerHTML = folder.emoji || FOLDER_HEROICONS[0];
|
||||
setSvgIconContent(iconSpan, folder.emoji || FOLDER_HEROICONS[0]);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = folder.name;
|
||||
item.appendChild(dot);
|
||||
@@ -810,7 +849,7 @@ const messageFoldersPlugin: Plugin<typeof messageFoldersSettings, MessageFolders
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "bsplus-msg-badge";
|
||||
badge.style.background = folder.color;
|
||||
badge.innerHTML = `${folder.emoji ? `<span style="display:inline-flex;vertical-align:middle;margin-right:2px">${folder.emoji}</span>` : ""}${folder.name}`;
|
||||
appendFolderBadgeContent(badge, folder);
|
||||
badge.title = `Filter by "${folder.name}"`;
|
||||
badge.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -62,6 +62,10 @@ const notificationCollectorPlugin: Plugin<{}, NotificationCollectorStorage> = {
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Heartbeat HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store notification count for history
|
||||
|
||||
@@ -10,8 +10,8 @@ import { BSPLUS_PENDING_THEME_ENSURE_AFTER_CLOUD_KEY } from "@/seqta/utils/cloud
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
import debounce from "@/seqta/utils/debounce";
|
||||
import { themeUpdates } from "@/interface/hooks/ThemeUpdates";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { getApiBase } from "@/seqta/utils/DevApiBase";
|
||||
import { isAllowedFetchUrl } from "@/seqta/utils/allowedFetchUrl";
|
||||
import { updateAllColors } from "@/seqta/ui/colors/Manager";
|
||||
import {
|
||||
clearCustomThemeAdaptiveCssVariables,
|
||||
@@ -667,8 +667,12 @@ export class ThemeManager {
|
||||
if (!downloadData?.success || !downloadData?.data?.theme_json_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(
|
||||
downloadData.data.theme_json_url,
|
||||
themeJsonUrl,
|
||||
)) as ThemeContent;
|
||||
} catch (apiError) {
|
||||
console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError);
|
||||
@@ -796,10 +800,8 @@ export class ThemeManager {
|
||||
this.storeUpdateCheckRunning = true;
|
||||
localStorage.setItem(ThemeManager.STORE_CHECK_KEY, String(Date.now()));
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemes",
|
||||
token: token ?? undefined,
|
||||
})) as {
|
||||
success?: boolean;
|
||||
data?: { themes?: Array<{ id: string; updated_at?: number }> };
|
||||
|
||||
@@ -84,6 +84,8 @@ async function handleTimetable(): Promise<void> {
|
||||
}
|
||||
|
||||
function handleTimetableZoom(): void {
|
||||
if (document.querySelector(".timetable-zoom-controls")) return;
|
||||
|
||||
console.log("Initializing timetable zoom controls");
|
||||
|
||||
// Create zoom controls
|
||||
@@ -130,6 +132,8 @@ function handleTimetableZoom(): void {
|
||||
}
|
||||
|
||||
function handleTimetableAssessmentHide(): void {
|
||||
if (document.querySelector(".timetable-hide-controls")) return;
|
||||
|
||||
const hideControls = document.createElement("div");
|
||||
hideControls.className = "timetable-hide-controls";
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@ function createSEQTAAPI(): SEQTAAPI {
|
||||
*/
|
||||
function createSettingsAPI<T extends PluginSettings>(
|
||||
plugin: Plugin<T>,
|
||||
): SettingsAPI<T> & { loaded: Promise<void> } {
|
||||
): {
|
||||
settings: SettingsAPI<T> & { loaded: Promise<void> };
|
||||
dispose: () => void;
|
||||
} {
|
||||
const storageKey = `plugin.${plugin.id}.settings`;
|
||||
const listeners = new Map<keyof T, Set<(value: any) => void>>();
|
||||
|
||||
@@ -167,6 +170,10 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
};
|
||||
|
||||
const proxy = new Proxy(settingsWithMeta, {
|
||||
get(target, prop) {
|
||||
return target[prop];
|
||||
@@ -183,6 +190,17 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
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 });
|
||||
|
||||
listeners.get(prop as keyof T)?.forEach((cb) => cb(value));
|
||||
@@ -190,18 +208,18 @@ function createSettingsAPI<T extends PluginSettings>(
|
||||
},
|
||||
}) as SettingsAPI<T> & { loaded: Promise<void> };
|
||||
|
||||
return proxy;
|
||||
return { settings: proxy, dispose };
|
||||
}
|
||||
|
||||
function createStorageAPI<T = any>(
|
||||
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 cache: Record<string, any> = {};
|
||||
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
|
||||
const loadStoragePromise = (async () => {
|
||||
@@ -243,10 +261,13 @@ function createStorageAPI<T = any>(
|
||||
}
|
||||
};
|
||||
browser.storage.onChanged.addListener(handleStorageChange);
|
||||
storageListeners.add(handleStorageChange);
|
||||
|
||||
const dispose = () => {
|
||||
browser.storage.onChanged.removeListener(handleStorageChange);
|
||||
};
|
||||
|
||||
// Create the proxy for direct property access
|
||||
return new Proxy(cache, {
|
||||
const storage = new Proxy(cache, {
|
||||
get(target, prop: string) {
|
||||
if (prop === "onChange") {
|
||||
return (key: keyof T, callback: (value: T[keyof T]) => void) => {
|
||||
@@ -288,6 +309,8 @@ function createStorageAPI<T = any>(
|
||||
return true;
|
||||
},
|
||||
}) as StorageAPI<T> & { [K in keyof T]: T[K] };
|
||||
|
||||
return { storage, dispose };
|
||||
}
|
||||
|
||||
function createEventsAPI(pluginId: string): EventsAPI {
|
||||
@@ -357,10 +380,17 @@ function createEventsAPI(pluginId: string): EventsAPI {
|
||||
export function createPluginAPI<T extends PluginSettings, S = any>(
|
||||
plugin: Plugin<T, S>,
|
||||
): PluginAPI<T, S> {
|
||||
const { settings, dispose: disposeSettings } = createSettingsAPI(plugin);
|
||||
const { storage, dispose: disposeStorage } = createStorageAPI<S>(plugin.id);
|
||||
|
||||
return {
|
||||
seqta: createSEQTAAPI(),
|
||||
settings: createSettingsAPI(plugin),
|
||||
storage: createStorageAPI<S>(plugin.id),
|
||||
settings,
|
||||
storage,
|
||||
events: createEventsAPI(plugin.id),
|
||||
dispose: () => {
|
||||
disposeSettings();
|
||||
disposeStorage();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class PluginManager {
|
||||
private runningPlugins: Map<string, boolean> = new Map();
|
||||
private eventBacklog: Map<string, any[]> = 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 styleElements: Map<string, HTMLStyleElement> = new Map();
|
||||
|
||||
@@ -148,6 +149,7 @@ export class PluginManager {
|
||||
|
||||
try {
|
||||
const api = createPluginAPI(plugin);
|
||||
this.apiDisposers.set(pluginId, api.dispose);
|
||||
|
||||
// Check if plugin is enabled before starting
|
||||
if (plugin.disableToggle) {
|
||||
@@ -158,6 +160,7 @@ export class PluginManager {
|
||||
const enabled =
|
||||
pluginSettings?.enabled ?? plugin.defaultEnabled ?? true;
|
||||
if (!enabled) {
|
||||
this.disposePluginAPI(pluginId);
|
||||
console.info(
|
||||
`Plugin "${pluginId}" is disabled, skipping initialization`,
|
||||
);
|
||||
@@ -186,6 +189,8 @@ export class PluginManager {
|
||||
// Process any backlogged events
|
||||
await this.processBackloggedEvents(pluginId);
|
||||
} catch (error) {
|
||||
this.removePluginStyles(pluginId);
|
||||
this.disposePluginAPI(pluginId);
|
||||
console.error(
|
||||
`[BetterSEQTA+] Failed to start plugin ${pluginId}:`,
|
||||
error,
|
||||
@@ -194,6 +199,22 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to start all registered plugins.
|
||||
* Errors during the start of individual plugins are caught and logged,
|
||||
@@ -225,12 +246,8 @@ export class PluginManager {
|
||||
* @returns {Promise<void>} A promise that resolves when the plugin has been stopped.
|
||||
*/
|
||||
public async stopPlugin(pluginId: string): Promise<void> {
|
||||
// Remove plugin styles
|
||||
const styleElement = this.styleElements.get(pluginId);
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
this.styleElements.delete(pluginId);
|
||||
}
|
||||
this.removePluginStyles(pluginId);
|
||||
this.disposePluginAPI(pluginId);
|
||||
|
||||
const cleanup = this.cleanupFunctions.get(pluginId);
|
||||
if (cleanup) {
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface PluginAPI<T extends PluginSettings, S = any> {
|
||||
settings: SettingsAPI<T>;
|
||||
storage: TypedStorageAPI<S>;
|
||||
events: EventsAPI;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface Plugin<T extends PluginSettings = PluginSettings, S = any> {
|
||||
|
||||
Reference in New Issue
Block a user